🔀 Commit

Telegram like pinned messages, updated from upstream (Conversations)
Commitb883db84e3406d5342b71c8226b6fc7b47a04f54
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-26
Parentab5d4b9d
commit b883db84e3406d5342b71c8226b6fc7b47a04f54
Author: JabJab <noreply@xmpp.tel>
Date:   Sun Jul 26 21:34:19 2026 +0300

    Telegram like pinned messages, updated from upstream (Conversations)
---
 build.gradle                                       |   8 +-
 gradle/wrapper/gradle-wrapper.properties           |   2 +-
 .../069d1e12bdf227f124fa7f76889c4134/results.bin   |   1 +
 .../annotation/annotation_dex/classes.dex          | Bin 0 -> 1424 bytes
 .../tel/xmpp/jabjab/ui/MagicCreateActivity.java    |   3 +-
 src/main/AndroidManifest.xml                       |   3 +
 src/main/java/de/gultsch/common/MiniUri.java       | 115 ++++++++-
 .../java/de/gultsch/minidns/AndroidDNSClient.java  |  13 +-
 .../im/conversations/android/json/Services.java    |  71 +++++
 .../android/xmpp/model/data/Field.java             |   5 +
 .../android/xmpp/model/data/Option.java            |  10 +
 .../android/xmpp/model/disco/items/Item.java       |   4 +
 .../android/xmpp/processor/BindProcessor.java      |   2 +-
 src/main/java/tel/xmpp/jabjab/Config.java          |   8 +
 .../java/tel/xmpp/jabjab/crypto/PgpEngine.java     |  17 +-
 .../jabjab/crypto/axolotl/SQLiteAxolotlStore.java  |  73 +-----
 .../java/tel/xmpp/jabjab/entities/Account.java     | 224 ++++++++--------
 .../tel/xmpp/jabjab/entities/Conversation.java     |  52 ++++
 src/main/java/tel/xmpp/jabjab/entities/Edit.java   | 101 ++------
 .../xmpp/jabjab/entities/IndividualMessage.java    |   4 +-
 .../java/tel/xmpp/jabjab/entities/Message.java     |  65 ++---
 .../java/tel/xmpp/jabjab/entities/Reaction.java    |  41 +--
 .../tel/xmpp/jabjab/entities/ReadByMarker.java     | 154 +++--------
 .../xmpp/jabjab/http/HttpConnectionManager.java    |   3 +-
 .../xmpp/jabjab/persistance/DatabaseBackend.java   |  18 +-
 .../jabjab/services/XmppConnectionService.java     |  11 +-
 .../tel/xmpp/jabjab/ui/BotCommandExecutor.java     | 286 +++++++++++++++++++++
 .../tel/xmpp/jabjab/ui/BotCommandFormDialog.java   | 267 +++++++++++++++++++
 .../tel/xmpp/jabjab/ui/BotCommandsFragment.java    | 223 ++++++++++++++++
 .../tel/xmpp/jabjab/ui/ContactDetailsActivity.java |   7 +
 .../tel/xmpp/jabjab/ui/ConversationFragment.java   | 237 ++++++++++++++++-
 .../tel/xmpp/jabjab/ui/EditAccountActivity.java    |  15 +-
 .../tel/xmpp/jabjab/ui/PinnedMessagesActivity.java | 161 ++++++++++++
 .../tel/xmpp/jabjab/ui/ShowLocationActivity.java   |  34 +--
 src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java |  12 +-
 .../xmpp/jabjab/ui/adapter/BotCommandAdapter.java  |  66 +++++
 .../tel/xmpp/jabjab/ui/adapter/MessageAdapter.java |  24 ++
 .../tel/xmpp/jabjab/ui/util/UpdateChecker.java     |   2 +-
 src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java |  71 ++---
 .../tel/xmpp/jabjab/worker/ImportBackupWorker.java |  21 +-
 .../xmpp/jabjab/worker/UpdateDownloadWorker.java   |   4 +-
 .../java/tel/xmpp/jabjab/xmpp/XmppConnection.java  |   7 +-
 .../tel/xmpp/jabjab/xmpp/manager/DiscoManager.java |  15 +-
 .../jabjab/xmpp/manager/JingleMessageManager.java  |   5 +-
 src/main/res/drawable/ic_expand_less_24dp.xml      |  10 +
 src/main/res/drawable/ic_expand_more_24dp.xml      |  10 +
 src/main/res/drawable/ic_push_pin_24dp.xml         |  10 +
 src/main/res/layout/activity_contact_details.xml   |  39 ++-
 src/main/res/layout/activity_pinned_messages.xml   |  42 +++
 src/main/res/layout/dialog_command_form.xml        |  15 ++
 src/main/res/layout/fragment_bot_commands.xml      |  30 +++
 src/main/res/layout/fragment_conversation.xml      | 144 ++++++++++-
 src/main/res/layout/item_bot_command.xml           |  30 +++
 src/main/res/layout/item_command_form_boolean.xml  |   6 +
 src/main/res/layout/item_command_form_list.xml     |  20 ++
 src/main/res/layout/item_command_form_text.xml     |  13 +
 src/main/res/layout/item_message_end.xml           |  11 +
 src/main/res/layout/item_message_start.xml         |  11 +
 src/main/res/menu/fragment_conversation.xml        |   6 +
 src/main/res/menu/message_context.xml              |  11 +
 src/main/res/menu/pinned_message_context.xml       |   8 +
 src/main/res/values-cs/strings.xml                 |  14 +-
 src/main/res/values-da-rDK/strings.xml             |   2 +-
 src/main/res/values-de/strings.xml                 |   2 +-
 src/main/res/values-es/strings.xml                 |   2 +-
 src/main/res/values-et/strings.xml                 |   2 +-
 src/main/res/values-fi/strings.xml                 |   2 +-
 src/main/res/values-fr/strings.xml                 |   2 +-
 src/main/res/values-gl/strings.xml                 |   7 +
 src/main/res/values-it/strings.xml                 |   2 +-
 src/main/res/values-night/themes.xml               |   8 +-
 src/main/res/values-nl/strings.xml                 |   2 +-
 src/main/res/values-pl/strings.xml                 |   2 +-
 src/main/res/values-pt-rBR/strings.xml             |   2 +-
 src/main/res/values-pt/strings.xml                 |   2 +-
 src/main/res/values-ro-rRO/strings.xml             |   2 +-
 src/main/res/values-ru/strings.xml                 |   2 +-
 src/main/res/values-sr/strings.xml                 |   2 +-
 src/main/res/values-sv/strings.xml                 |   2 +-
 src/main/res/values-tr-rTR/strings.xml             | 115 +++++++--
 src/main/res/values/strings.xml                    |  22 +-
 src/main/res/values/themes.xml                     |   2 +-
 src/quicksy/res/values-tr-rTR/strings.xml          |   1 +
 83 files changed, 2434 insertions(+), 646 deletions(-)

diff --git a/build.gradle b/build.gradle
index 56af7f1..304813f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -6,7 +6,7 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:9.0.1'
+        classpath 'com.android.tools.build:gradle:9.2.1'
         classpath 'com.diffplug.spotless:spotless-plugin-gradle:8.6.0'
     }
 }
@@ -113,8 +113,8 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42301
-        versionName "1.0.2"
+        versionCode 42302
+        versionName "1.0.3"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
         def appName = "JabJab"
@@ -198,11 +198,9 @@ android {
 
         playstore {
             dimension "distribution"
-            versionNameSuffix "+playstore"
         }
         free {
             dimension "distribution"
-            versionNameSuffix "+free"
         }
     }
 
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 23449a2..c61a118 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
diff --git a/libs/annotation/build/.transforms/069d1e12bdf227f124fa7f76889c4134/results.bin b/libs/annotation/build/.transforms/069d1e12bdf227f124fa7f76889c4134/results.bin
new file mode 100644
index 0000000..1a31b12
--- /dev/null
+++ b/libs/annotation/build/.transforms/069d1e12bdf227f124fa7f76889c4134/results.bin
@@ -0,0 +1 @@
+o/annotation
diff --git a/libs/annotation/build/.transforms/069d1e12bdf227f124fa7f76889c4134/transformed/annotation/annotation_dex/classes.dex b/libs/annotation/build/.transforms/069d1e12bdf227f124fa7f76889c4134/transformed/annotation/annotation_dex/classes.dex
new file mode 100644
index 0000000..2af3c83
Binary files /dev/null and b/libs/annotation/build/.transforms/069d1e12bdf227f124fa7f76889c4134/transformed/annotation/annotation_dex/classes.dex differ
diff --git a/src/conversations/java/tel/xmpp/jabjab/ui/MagicCreateActivity.java b/src/conversations/java/tel/xmpp/jabjab/ui/MagicCreateActivity.java
index 61a12bc..31519d6 100644
--- a/src/conversations/java/tel/xmpp/jabjab/ui/MagicCreateActivity.java
+++ b/src/conversations/java/tel/xmpp/jabjab/ui/MagicCreateActivity.java
@@ -90,8 +90,7 @@ public class MagicCreateActivity extends XmppActivity implements TextWatcher {
                                 account.setOption(Account.OPTION_MAGIC_CREATE, true);
                                 account.setOption(Account.OPTION_FIXED_USERNAME, fixedUsername);
                                 if (this.preAuth != null) {
-                                    account.setKey(
-                                            Account.KEY_PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
+                                    account.setPreAuthRegistrationToken(this.preAuth);
                                 }
                                 xmppConnectionService.createAccount(account);
                             }
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 7a0c903..3c01ef5 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -185,6 +185,9 @@
         <activity
             android:name=".ui.SearchActivity"
             android:label="@string/search_messages" />
+        <activity
+            android:name=".ui.PinnedMessagesActivity"
+            android:label="@string/pinned_messages" />
         <activity
             android:name=".ui.RecordingActivity"
             android:configChanges="orientation|screenSize"
diff --git a/src/main/java/de/gultsch/common/MiniUri.java b/src/main/java/de/gultsch/common/MiniUri.java
index c098695..0fa1ed0 100644
--- a/src/main/java/de/gultsch/common/MiniUri.java
+++ b/src/main/java/de/gultsch/common/MiniUri.java
@@ -3,6 +3,7 @@ package de.gultsch.common;
 import android.net.Uri;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
+import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
@@ -14,7 +15,9 @@ import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
 import com.google.common.escape.CharEscaper;
 import com.google.common.primitives.Chars;
+import com.google.common.primitives.Doubles;
 import com.google.common.primitives.Ints;
+import tel.xmpp.jabjab.utils.CharSequences;
 import tel.xmpp.jabjab.xmpp.Jid;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
@@ -31,6 +34,7 @@ import java.util.regex.Pattern;
 import okhttp3.HttpUrl;
 import org.jspecify.annotations.NonNull;
 import org.jspecify.annotations.Nullable;
+import org.osmdroid.util.GeoPoint;
 
 public class MiniUri {
 
@@ -90,8 +94,13 @@ public class MiniUri {
 
     private static Map<String, Collection<String>> parseParameters(
             final String query, final char separator) {
+        return parseParameters(Splitter.on(separator).omitEmptyStrings().split(query));
+    }
+
+    protected static Map<String, Collection<String>> parseParameters(
+            final Iterable<String> iterable) {
         final var builder = new ImmutableMultimap.Builder<String, String>();
-        for (final String pair : Splitter.on(separator).omitEmptyStrings().split(query)) {
+        for (final String pair : iterable) {
             final String[] parts = pair.split("=", 2);
             if (parts.length == 0) {
                 continue;
@@ -268,7 +277,12 @@ public class MiniUri {
                 throw new IllegalArgumentException("HTTP URI does not match pattern");
             }
             case "mumble" -> asMiniUriIfMatch(Patterns.URI_MUMBLE, uri);
-            case "geo" -> asMiniUriIfMatch(Patterns.URI_GEO, uri);
+            case "geo" -> {
+                if (Patterns.URI_GEO.matcher(uri).matches()) {
+                    yield new Geo(uri);
+                }
+                throw new IllegalArgumentException("GEO URI does not match pattern");
+            }
             case "xmpp" -> new Xmpp(uri);
             case "taler" -> asMiniUriIfMatch(Patterns.URI_TALER, uri);
             case "imto" -> new Imto(uri);
@@ -506,6 +520,103 @@ public class MiniUri {
         }
     }
 
+    public static class Geo extends MiniUri {
+
+        private final double latitude;
+        private final double longitude;
+        private final Map<String, Collection<String>> geoParameters;
+
+        private Geo(final String uri) {
+            super(uri);
+            final var pathComponents = Splitter.on(';').splitToList(getPath());
+            final var coordinates =
+                    Splitter.on(',')
+                            .splitToList(
+                                    Iterables.getFirst(pathComponents, CharSequences.EMPTY_STRING));
+            if (coordinates.size() != 2) {
+                throw new IllegalArgumentException("invalid coordinates");
+            }
+            final var latitude = Doubles.tryParse(coordinates.get(0));
+            final var longitude = Doubles.tryParse(coordinates.get(1));
+            if (latitude == null || longitude == null) {
+                throw new IllegalArgumentException("lat/long did not contain valid doubles");
+            }
+            this.latitude = latitude;
+            this.longitude = longitude;
+            if (pathComponents.size() >= 2) {
+                this.geoParameters = parseParameters(Iterables.skip(pathComponents, 1));
+            } else {
+                this.geoParameters = Collections.emptyMap();
+            }
+        }
+
+        public Geo(final GeoPoint geoPoint) {
+            this(geoPoint.getLatitude(), geoPoint.getLongitude());
+        }
+
+        public Geo(final double latitude, final double longitude) {
+            super(String.format(Locale.US, "geo:%s,%s", latitude, longitude));
+            this.latitude = latitude;
+            this.longitude = longitude;
+            this.geoParameters = Collections.emptyMap();
+        }
+
+        public double getLatitude() {
+            return this.latitude;
+        }
+
+        public double getLongitude() {
+            return this.longitude;
+        }
+
+        public Optional<Integer> getUncertainty() {
+            final var u = this.geoParameters.get("u");
+            if (u == null || u.isEmpty()) {
+                return Optional.absent();
+            }
+            final var value = Iterables.getFirst(u, null);
+            return value == null ? null : Optional.fromNullable(Ints.tryParse(value));
+        }
+
+        public Optional<Integer> getZoom() {
+            final var z = this.getParameter("z");
+            if (z == null) {
+                return Optional.absent();
+            }
+            return Optional.fromNullable(Ints.tryParse(z));
+        }
+
+        public Uri asUniversalUri() {
+            return asUniversalUri(null);
+        }
+
+        public Uri asUniversalUri(final String label) {
+            final String uri;
+            if (Strings.isNullOrEmpty(label)) {
+                uri =
+                        String.format(
+                                Locale.US,
+                                "geo:%1$s,%2$s?q=%1$s,%2$s",
+                                this.latitude,
+                                this.longitude);
+
+            } else {
+                uri =
+                        String.format(
+                                Locale.US,
+                                "geo:%1$s,%2$s?q=%1$s,%2$s(%3$s)",
+                                this.latitude,
+                                this.longitude,
+                                MiniUri.urlEncode(label));
+            }
+            return Uri.parse(uri);
+        }
+
+        public GeoPoint asGeoPoint() {
+            return new GeoPoint(latitude, longitude);
+        }
+    }
+
     private static final CharEscaper LIGHT_URI_ESCAPER =
             new CharEscaper() {
 
diff --git a/src/main/java/de/gultsch/minidns/AndroidDNSClient.java b/src/main/java/de/gultsch/minidns/AndroidDNSClient.java
index 2de54c7..6e09479 100644
--- a/src/main/java/de/gultsch/minidns/AndroidDNSClient.java
+++ b/src/main/java/de/gultsch/minidns/AndroidDNSClient.java
@@ -88,7 +88,13 @@ public class AndroidDNSClient extends AbstractDnsClient {
 
     protected ListenableFuture<DnsQueryResult> queryAsFuture(
             final DnsMessage.Builder queryBuilder) {
-        final var dnsServers = getDNSServers();
+        final List<DNSServer> dnsServers;
+        try {
+            dnsServers = getDNSServers();
+        } catch (final RuntimeException e) {
+            // likely DeadSystemExceptions from working on a dead ConnectivityManager
+            return Futures.immediateFailedFuture(e);
+        }
         final DnsMessage question = newQuestion(queryBuilder).build();
         final var rawFuture = queryAsFuture(question, new LinkedList<>(dnsServers));
         // allow for enough time to hit 2 servers over UDP and TCP one after another
@@ -179,13 +185,12 @@ public class AndroidDNSClient extends AbstractDnsClient {
             Log.e(Config.LOGTAG, "no DNS servers found. Context not ready");
             return Collections.emptyList();
         }
-        final ConnectivityManager connectivityManager =
-                c.getSystemService(ConnectivityManager.class);
+        final var connectivityManager = c.getSystemService(ConnectivityManager.class);
         if (connectivityManager == null) {
             Log.w(Config.LOGTAG, "no DNS servers found. ConnectivityManager was null");
             return Collections.emptyList();
         }
-        final Network activeNetwork = connectivityManager.getActiveNetwork();
+        final var activeNetwork = connectivityManager.getActiveNetwork();
         final List<DNSServer> activeDnsServers =
                 activeNetwork == null
                         ? Collections.emptyList()
diff --git a/src/main/java/im/conversations/android/json/Services.java b/src/main/java/im/conversations/android/json/Services.java
new file mode 100644
index 0000000..8f68fb8
--- /dev/null
+++ b/src/main/java/im/conversations/android/json/Services.java
@@ -0,0 +1,71 @@
+package im.conversations.android.json;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+import tel.xmpp.jabjab.xmpp.Jid;
+import java.io.IOException;
+import okhttp3.HttpUrl;
+
+public class Services {
+
+    public static final Gson GSON;
+
+    static {
+        GSON =
+                new GsonBuilder()
+                        .registerTypeAdapter(Jid.class, new JidTypeAdapter())
+                        .registerTypeAdapter(HttpUrl.class, new HttpUrlTypeAdapter())
+                        .create();
+    }
+
+    private static class HttpUrlTypeAdapter extends TypeAdapter<HttpUrl> {
+
+        @Override
+        public void write(final JsonWriter out, final HttpUrl value) throws IOException {
+            if (value == null) {
+                out.nullValue();
+            } else {
+                out.value(value.toString());
+            }
+        }
+
+        @Override
+        public HttpUrl read(final JsonReader in) throws IOException {
+            if (in.peek() == JsonToken.NULL) {
+                in.nextNull();
+                return null;
+            } else if (in.peek() == JsonToken.STRING) {
+                final String value = in.nextString();
+                return HttpUrl.parse(value);
+            }
+            throw new IOException("Unexpected token");
+        }
+    }
+
+    private static class JidTypeAdapter extends TypeAdapter<Jid> {
+        @Override
+        public void write(final JsonWriter out, final Jid value) throws IOException {
+            if (value == null) {
+                out.nullValue();
+            } else {
+                out.value(value.toString());
+            }
+        }
+
+        @Override
+        public Jid read(final JsonReader in) throws IOException {
+            if (in.peek() == JsonToken.NULL) {
+                in.nextNull();
+                return null;
+            } else if (in.peek() == JsonToken.STRING) {
+                final String value = in.nextString();
+                return Jid.of(value);
+            }
+            throw new IOException("Unexpected token");
+        }
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/data/Field.java b/src/main/java/im/conversations/android/xmpp/model/data/Field.java
index c22a350..a172801 100644
--- a/src/main/java/im/conversations/android/xmpp/model/data/Field.java
+++ b/src/main/java/im/conversations/android/xmpp/model/data/Field.java
@@ -1,5 +1,6 @@
 package im.conversations.android.xmpp.model.data;
 
+import androidx.annotation.Nullable;
 import com.google.common.base.CaseFormat;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
@@ -21,6 +22,10 @@ public class Field extends Extension {
         return getAttribute("var");
     }
 
+    public @Nullable String getLabel() {
+        return this.getAttribute("label");
+    }
+
     public Collection<String> getValuesRaw() {
         return Collections2.transform(getExtensions(Value.class), Element::getContent);
     }
diff --git a/src/main/java/im/conversations/android/xmpp/model/data/Option.java b/src/main/java/im/conversations/android/xmpp/model/data/Option.java
index b9c3e9a..f303b36 100644
--- a/src/main/java/im/conversations/android/xmpp/model/data/Option.java
+++ b/src/main/java/im/conversations/android/xmpp/model/data/Option.java
@@ -1,5 +1,6 @@
 package im.conversations.android.xmpp.model.data;
 
+import androidx.annotation.Nullable;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 
@@ -9,4 +10,13 @@ public class Option extends Extension {
     public Option() {
         super(Option.class);
     }
+
+    public @Nullable String getLabel() {
+        return this.getAttribute("label");
+    }
+
+    public @Nullable String getValue() {
+        final var value = getOnlyExtension(Value.class);
+        return value == null ? null : value.getContent();
+    }
 }
diff --git a/src/main/java/im/conversations/android/xmpp/model/disco/items/Item.java b/src/main/java/im/conversations/android/xmpp/model/disco/items/Item.java
index d85bb96..cb22865 100644
--- a/src/main/java/im/conversations/android/xmpp/model/disco/items/Item.java
+++ b/src/main/java/im/conversations/android/xmpp/model/disco/items/Item.java
@@ -19,4 +19,8 @@ public class Item extends Extension {
     public @Nullable String getNode() {
         return this.getAttribute("node");
     }
+
+    public @Nullable String getItemName() {
+        return this.getAttribute("name");
+    }
 }
diff --git a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java b/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java
index f78fb92..41230e7 100644
--- a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java
+++ b/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java
@@ -41,7 +41,7 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
         final var sos = features.getServiceOutageStatus();
         if (sos != null) {
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + " server has SOS on " + sos);
-            sosModified = account.setKey(Account.KEY_SOS_URL, sos.toString());
+            sosModified = account.setSosUrl(sos);
         } else {
             sosModified = false;
         }
diff --git a/src/main/java/tel/xmpp/jabjab/Config.java b/src/main/java/tel/xmpp/jabjab/Config.java
index 3250075..853c5dc 100644
--- a/src/main/java/tel/xmpp/jabjab/Config.java
+++ b/src/main/java/tel/xmpp/jabjab/Config.java
@@ -2,6 +2,7 @@ package tel.xmpp.jabjab;
 
 import android.graphics.Bitmap;
 import android.net.Uri;
+import com.google.common.collect.ImmutableSet;
 import tel.xmpp.jabjab.xmpp.Jid;
 import im.conversations.android.xmpp.model.state.Active;
 import im.conversations.android.xmpp.model.state.ChatStateNotification;
@@ -13,6 +14,13 @@ public final class Config {
     public static final boolean QUICK_LOG = false;
 
     public static final Jid BUG_REPORTS = Jid.of("report@xmpp.tel");
+    // Contacts that expose XEP-0050 ad-hoc commands surfaced via a "Commands" tab
+    // in their chat window (see ConversationFragment/BotCommandsFragment).
+    public static final ImmutableSet<Jid> COMMAND_CAPABLE_JIDS =
+            ImmutableSet.of(Jid.of("data@xmpp.tel"));
+    // Contacts shown with a "verified" checkmark badge next to their name so users know
+    // they're an official/trusted account (see ConversationFragment#updateToolbar).
+    public static final ImmutableSet<Jid> VERIFIED_JIDS = ImmutableSet.of(Jid.of("data@xmpp.tel"));
     public static final Uri HELP = Uri.parse("https://help.conversations.im");
     // Kept null so xmpp.tel is not added to SECURE_DOMAINS (which requires channel binding).
     // The wizard passes EXTRA_DOMAIN="xmpp.tel" to MagicCreateActivity directly.
diff --git a/src/main/java/tel/xmpp/jabjab/crypto/PgpEngine.java b/src/main/java/tel/xmpp/jabjab/crypto/PgpEngine.java
index ce18d5b..a3f9b5b 100644
--- a/src/main/java/tel/xmpp/jabjab/crypto/PgpEngine.java
+++ b/src/main/java/tel/xmpp/jabjab/crypto/PgpEngine.java
@@ -79,9 +79,11 @@ public class PgpEngine {
         params.setAction(OpenPgpApi.ACTION_ENCRYPT);
         final Conversation conversation = (Conversation) message.getConversation();
         if (conversation.getMode() == Conversation.MODE_SINGLE) {
-            long[] keys = {
-                conversation.getContact().getPgpKeyId(), conversation.getAccount().getPgpId()
-            };
+            final var pgpId = conversation.getAccount().getPgpId();
+            if (!pgpId.isPresent()) {
+                settableFuture.setException(new IllegalStateException("PGP not set up"));
+            }
+            final long[] keys = {conversation.getContact().getPgpKeyId(), pgpId.get()};
             params.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keys);
         } else {
             params.putExtra(OpenPgpApi.EXTRA_KEY_IDS, conversation.getMucOptions().getPgpKeyIds());
@@ -265,16 +267,17 @@ public class PgpEngine {
     }
 
     public ListenableFuture<String> generateSignature(
-            Intent intent, final Account account, final String status) {
-        if (account.getPgpId() == 0) {
+            final Intent intent, final Account account, final String status) {
+        final var pgpId = account.getPgpId();
+        if (!pgpId.isPresent()) {
             return Futures.immediateFailedFuture(
                     new IllegalStateException("Account has no PGP ID"));
         }
         final SettableFuture<String> future = SettableFuture.create();
-        Intent params = intent == null ? new Intent() : intent;
+        final Intent params = intent == null ? new Intent() : intent;
         params.setAction(OpenPgpApi.ACTION_CLEARTEXT_SIGN);
         params.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true);
-        params.putExtra(OpenPgpApi.EXTRA_SIGN_KEY_ID, account.getPgpId());
+        params.putExtra(OpenPgpApi.EXTRA_SIGN_KEY_ID, pgpId.get());
         InputStream is = new ByteArrayInputStream(status.getBytes());
         final OutputStream os = new ByteArrayOutputStream();
         Log.d(
diff --git a/src/main/java/tel/xmpp/jabjab/crypto/axolotl/SQLiteAxolotlStore.java b/src/main/java/tel/xmpp/jabjab/crypto/axolotl/SQLiteAxolotlStore.java
index dc60c68..14be7e5 100644
--- a/src/main/java/tel/xmpp/jabjab/crypto/axolotl/SQLiteAxolotlStore.java
+++ b/src/main/java/tel/xmpp/jabjab/crypto/axolotl/SQLiteAxolotlStore.java
@@ -41,17 +41,12 @@ public class SQLiteAxolotlStore implements SignalProtocolStore {
     public static final String OWN = "ownkey";
     public static final String CERTIFICATE = "certificate";
 
-    public static final String JSONKEY_REGISTRATION_ID = "axolotl_reg_id";
-    public static final String JSONKEY_CURRENT_PREKEY_ID = "axolotl_cur_prekey_id";
-
     private static final int NUM_TRUSTS_TO_CACHE = 100;
 
     private final Account account;
     private final XmppConnectionService mXmppConnectionService;
 
     private IdentityKeyPair identityKeyPair;
-    private int localRegistrationId;
-    private int currentPreKeyId = 0;
 
     private final HashSet<Integer> preKeysMarkedForRemoval = new HashSet<>();
 
@@ -84,12 +79,10 @@ public class SQLiteAxolotlStore implements SignalProtocolStore {
     public SQLiteAxolotlStore(Account account, XmppConnectionService service) {
         this.account = account;
         this.mXmppConnectionService = service;
-        this.localRegistrationId = loadRegistrationId();
-        this.currentPreKeyId = loadCurrentPreKeyId();
     }
 
     public int getCurrentPreKeyId() {
-        return currentPreKeyId;
+        return this.account.getAxolotlCurrentPreKey();
     }
 
     // --------------------------------------
@@ -120,54 +113,23 @@ public class SQLiteAxolotlStore implements SignalProtocolStore {
     }
 
     private int loadRegistrationId(boolean regenerate) {
-        String regIdString = this.account.getKey(JSONKEY_REGISTRATION_ID);
+        final var registrationId = this.account.getAxolotlRegistrationId();
         int reg_id;
-        if (!regenerate && regIdString != null) {
-            reg_id = Integer.valueOf(regIdString);
-        } else {
-            Log.i(
-                    Config.LOGTAG,
-                    AxolotlService.getLogprefix(account)
-                            + "Could not retrieve axolotl registration id for account "
-                            + account.getJid());
-            reg_id = generateRegistrationId();
-            boolean success =
-                    this.account.setKey(JSONKEY_REGISTRATION_ID, Integer.toString(reg_id));
-            if (success) {
-                mXmppConnectionService.databaseBackend.updateAccount(account);
-            } else {
-                Log.e(
-                        Config.LOGTAG,
-                        AxolotlService.getLogprefix(account)
-                                + "Failed to write new key to the database!");
-            }
-        }
-        return reg_id;
-    }
-
-    private int loadCurrentPreKeyId() {
-        String prekeyIdString = this.account.getKey(JSONKEY_CURRENT_PREKEY_ID);
-        int prekey_id;
-        if (prekeyIdString != null) {
-            prekey_id = Integer.valueOf(prekeyIdString);
+        if (regenerate || !registrationId.isPresent()) {
+            final var id = generateRegistrationId();
+            this.account.setAxolotlRegistrationId(id);
+            return id;
         } else {
-            Log.w(
-                    Config.LOGTAG,
-                    AxolotlService.getLogprefix(account)
-                            + "Could not retrieve current prekey id for account "
-                            + account.getJid());
-            prekey_id = 0;
+            return registrationId.get();
         }
-        return prekey_id;
     }
 
     public void regenerate() {
         mXmppConnectionService.databaseBackend.wipeAxolotlDb(account);
         trustCache.evictAll();
-        account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(0));
         identityKeyPair = loadIdentityKeyPair();
-        localRegistrationId = loadRegistrationId(true);
-        currentPreKeyId = 0;
+        loadRegistrationId(true);
+        this.account.setAxolotlCurrentPreKey(0);
         mXmppConnectionService.updateAccountUi();
     }
 
@@ -194,7 +156,7 @@ public class SQLiteAxolotlStore implements SignalProtocolStore {
      */
     @Override
     public int getLocalRegistrationId() {
-        return localRegistrationId;
+        return loadRegistrationId(false);
     }
 
     /**
@@ -395,19 +357,10 @@ public class SQLiteAxolotlStore implements SignalProtocolStore {
      * @param record the PreKeyRecord.
      */
     @Override
-    public void storePreKey(int preKeyId, PreKeyRecord record) {
+    public void storePreKey(final int preKeyId, final PreKeyRecord record) {
         mXmppConnectionService.databaseBackend.storePreKey(account, record);
-        currentPreKeyId = preKeyId;
-        boolean success =
-                this.account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(preKeyId));
-        if (success) {
-            mXmppConnectionService.databaseBackend.updateAccount(account);
-        } else {
-            Log.e(
-                    Config.LOGTAG,
-                    AxolotlService.getLogprefix(account)
-                            + "Failed to write new prekey id to the database!");
-        }
+        account.setAxolotlCurrentPreKey(preKeyId);
+        mXmppConnectionService.databaseBackend.updateAccount(account);
     }
 
     /**
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Account.java b/src/main/java/tel/xmpp/jabjab/entities/Account.java
index 7233689..ec61c27 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Account.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Account.java
@@ -3,10 +3,13 @@ package tel.xmpp.jabjab.entities;
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.util.Log;
-import androidx.annotation.NonNull;
+import com.google.common.base.Objects;
+import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
+import com.google.gson.JsonParseException;
+import com.google.gson.annotations.SerializedName;
 import de.gultsch.common.MiniUri;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
@@ -27,10 +30,12 @@ import tel.xmpp.jabjab.xmpp.XmppConnection;
 import tel.xmpp.jabjab.xmpp.jingle.RtpCapability;
 import tel.xmpp.jabjab.xmpp.manager.HttpUploadManager;
 import tel.xmpp.jabjab.xmpp.manager.RosterManager;
+import im.conversations.android.json.Services;
 import java.util.Arrays;
 import java.util.Collection;
-import org.json.JSONException;
-import org.json.JSONObject;
+import java.util.Locale;
+import okhttp3.HttpUrl;
+import org.jspecify.annotations.NonNull;
 
 public class Account extends AbstractEntity implements AvatarService.Avatar {
 
@@ -65,12 +70,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
     public static final int OPTION_QUICKSTART_AVAILABLE = 10;
     public static final int OPTION_SOFT_DISABLED = 11;
 
-    private static final String KEY_PGP_SIGNATURE = "pgp_signature";
-    private static final String KEY_PGP_ID = "pgp_id";
-    private static final String KEY_PINNED_MECHANISM = "pinned_mechanism";
-    public static final String KEY_SOS_URL = "sos_url";
-    public static final String KEY_PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
-    protected final JSONObject keys;
+    protected final Keys keys;
     protected Jid jid;
     protected String password;
     protected int options = 0;
@@ -99,7 +99,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
                 password,
                 0,
                 null,
-                "",
+                new Keys(),
                 null,
                 null,
                 null,
@@ -118,7 +118,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
             final String password,
             final int options,
             final String rosterVersion,
-            final String keys,
+            final Keys keys,
             final String avatar,
             String displayName,
             String hostname,
@@ -134,7 +134,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         this.password = password;
         this.options = options;
         this.rosterVersion = rosterVersion;
-        this.keys = parseKeys(keys);
+        this.keys = keys;
         this.avatar = avatar;
         this.displayName = displayName;
         this.hostname = hostname;
@@ -147,17 +147,6 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         this.fastToken = fastToken;
     }
 
-    public static JSONObject parseKeys(final String keys) {
-        if (Strings.isNullOrEmpty(keys)) {
-            return new JSONObject();
-        }
-        try {
-            return new JSONObject(keys);
-        } catch (final JSONException e) {
-            return new JSONObject();
-        }
-    }
-
     public static Account fromCursor(final Cursor cursor) {
         final Jid jid;
         try {
@@ -181,7 +170,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
                 cursor.getString(cursor.getColumnIndexOrThrow(PASSWORD)),
                 cursor.getInt(cursor.getColumnIndexOrThrow(OPTIONS)),
                 cursor.getString(cursor.getColumnIndexOrThrow(ROSTERVERSION)),
-                cursor.getString(cursor.getColumnIndexOrThrow(KEYS)),
+                Keys.parse(cursor.getString(cursor.getColumnIndexOrThrow(KEYS))),
                 cursor.getString(cursor.getColumnIndexOrThrow(AVATAR)),
                 cursor.getString(cursor.getColumnIndexOrThrow(DISPLAY_NAME)),
                 cursor.getString(cursor.getColumnIndexOrThrow(HOSTNAME)),
@@ -225,15 +214,14 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         return getPgpDecryptionService().isConnected();
     }
 
-    public boolean setShowErrorNotification(boolean newValue) {
-        boolean oldValue = showErrorNotification();
-        setKey("show_error", Boolean.toString(newValue));
+    public boolean setShowErrorNotification(final boolean newValue) {
+        final boolean oldValue = this.showErrorNotification();
+        this.keys.showError = newValue;
         return newValue != oldValue;
     }
 
     public boolean showErrorNotification() {
-        String key = getKey("show_error");
-        return key == null || Boolean.parseBoolean(key);
+        return this.keys.showError == null || this.keys.showError;
     }
 
     public boolean isEnabled() {
@@ -375,17 +363,12 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
     public void resetPinnedMechanism() {
         this.pinnedMechanism = null;
         this.pinnedChannelBinding = null;
-        setKey(Account.KEY_PINNED_MECHANISM, String.valueOf(-1));
     }
 
     public int getPinnedMechanismPriority() {
-        final int fallback = getKeyAsInt(KEY_PINNED_MECHANISM, -1);
-        if (Strings.isNullOrEmpty(this.pinnedMechanism)) {
-            return fallback;
-        }
         final SaslMechanism saslMechanism = getPinnedMechanism();
         if (saslMechanism == null) {
-            return fallback;
+            return Integer.MIN_VALUE;
         } else {
             return saslMechanism.getPriority();
         }
@@ -466,42 +449,12 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         return jid;
     }
 
-    public JSONObject getKeys() {
-        return keys;
-    }
-
-    public String getKey(final String name) {
-        synchronized (this.keys) {
-            return this.keys.optString(name, null);
-        }
-    }
-
-    public int getKeyAsInt(final String name, int defaultValue) {
-        String key = getKey(name);
-        try {
-            return key == null ? defaultValue : Integer.parseInt(key);
-        } catch (NumberFormatException e) {
-            return defaultValue;
-        }
-    }
-
-    public boolean setKey(final String keyName, final String keyValue) {
-        synchronized (this.keys) {
-            try {
-                this.keys.put(keyName, keyValue);
-                return true;
-            } catch (final JSONException e) {
-                return false;
-            }
-        }
-    }
-
     public void setPrivateKeyAlias(final String alias) {
-        setKey("private_key_alias", alias);
+        this.keys.privateKeyAlias = alias;
     }
 
     public String getPrivateKeyAlias() {
-        return getKey("private_key_alias");
+        return this.keys.privateKeyAlias;
     }
 
     @Override
@@ -512,9 +465,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         values.put(SERVER, jid.getDomain().toString());
         values.put(PASSWORD, password);
         values.put(OPTIONS, options);
-        synchronized (this.keys) {
-            values.put(KEYS, this.keys.toString());
-        }
+        values.put(KEYS, Services.GSON.toJson(this.keys));
         values.put(ROSTERVERSION, rosterVersion);
         values.put(AVATAR, avatar);
         values.put(DISPLAY_NAME, displayName);
@@ -569,46 +520,58 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
     }
 
     public String getPgpSignature() {
-        return getKey(KEY_PGP_SIGNATURE);
+        return this.keys.pgpSignature;
     }
 
-    public boolean setPgpSignature(String signature) {
-        return setKey(KEY_PGP_SIGNATURE, signature);
+    public void setPgpSignature(final String signature) {
+        this.keys.pgpSignature = signature;
     }
 
-    public boolean unsetPgpSignature() {
-        synchronized (this.keys) {
-            return keys.remove(KEY_PGP_SIGNATURE) != null;
-        }
+    public void resetPgp() {
+        this.keys.pgpKeyId = null;
+        this.keys.pgpSignature = null;
     }
 
-    public long getPgpId() {
-        synchronized (this.keys) {
-            if (keys.has(KEY_PGP_ID)) {
-                try {
-                    return keys.getLong(KEY_PGP_ID);
-                } catch (JSONException e) {
-                    return 0;
-                }
-            } else {
-                return 0;
-            }
-        }
+    public Optional<Long> getPgpId() {
+        return Optional.fromNullable(this.keys.pgpKeyId);
     }
 
-    public boolean setPgpSignId(long pgpID) {
-        synchronized (this.keys) {
-            try {
-                if (pgpID == 0) {
-                    keys.remove(KEY_PGP_ID);
-                } else {
-                    keys.put(KEY_PGP_ID, pgpID);
-                }
-            } catch (JSONException e) {
-                return false;
-            }
-            return true;
-        }
+    public void setPgpSignId(final long pgpID) {
+        this.keys.pgpKeyId = pgpID;
+    }
+
+    public Optional<Integer> getAxolotlRegistrationId() {
+        return this.keys.getAxolotlRegistrationId();
+    }
+
+    public void setAxolotlRegistrationId(final int registrationId) {
+        this.keys.axolotlRegistrationId = registrationId;
+    }
+
+    public int getAxolotlCurrentPreKey() {
+        return this.keys.axolotlCurrentPreKey;
+    }
+
+    public void setAxolotlCurrentPreKey(final int id) {
+        this.keys.axolotlCurrentPreKey = id;
+    }
+
+    public Optional<String> getPreAuthRegistrationToken() {
+        return Optional.fromNullable(Strings.emptyToNull(this.keys.preAuthRegistrationToken));
+    }
+
+    public void setPreAuthRegistrationToken(final String preAuthRegistrationToken) {
+        this.keys.preAuthRegistrationToken = preAuthRegistrationToken;
+    }
+
+    public Optional<HttpUrl> getSosUrl() {
+        return Optional.fromNullable(this.keys.sosUrl);
+    }
+
+    public boolean setSosUrl(final HttpUrl url) {
+        final var old = this.keys.sosUrl;
+        this.keys.sosUrl = url;
+        return !Objects.equal(old, url);
     }
 
     public Roster getRoster() {
@@ -636,12 +599,15 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         final ImmutableMultimap.Builder<String, String> builder = new ImmutableMultimap.Builder<>();
         final var axolotlService = getAxolotlService();
         builder.put(
-                String.format("omemo-sid-%d", axolotlService.getOwnDeviceId()),
+                String.format(Locale.US, "omemo-sid-%d", axolotlService.getOwnDeviceId()),
                 axolotlService.getOwnFingerprint().substring(2));
         for (final var session : axolotlService.findOwnSessions()) {
             if (session.getTrust().isVerified() && session.getTrust().isActive()) {
                 builder.put(
-                        String.format("omemo-sid-%d", session.getRemoteAddress().getDeviceId()),
+                        String.format(
+                                Locale.US,
+                                "omemo-sid-%d",
+                                session.getRemoteAddress().getDeviceId()),
                         session.getFingerprint().substring(2));
             }
         }
@@ -679,6 +645,55 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         this.xmppConnection = connection;
     }
 
+    public static class Keys {
+
+        @SerializedName("pgp_signature")
+        private String pgpSignature;
+
+        @SerializedName("pgp_id")
+        private Long pgpKeyId;
+
+        @SerializedName("sos_url")
+        private HttpUrl sosUrl;
+
+        @SerializedName("pre_auth_registration")
+        private String preAuthRegistrationToken;
+
+        @SerializedName("show_error")
+        private Boolean showError;
+
+        @SerializedName("private_key_alias")
+        private String privateKeyAlias;
+
+        @SerializedName("axolotl_reg_id")
+        private Integer axolotlRegistrationId;
+
+        @SerializedName("axolotl_cur_prekey_id")
+        private int axolotlCurrentPreKey;
+
+        public Optional<Integer> getAxolotlRegistrationId() {
+            return Optional.fromNullable(this.axolotlRegistrationId);
+        }
+
+        public static Keys parse(final String json) {
+            if (Strings.isNullOrEmpty(json)) {
+                return new Keys();
+            }
+            try {
+                return Services.GSON.fromJson(json, Keys.class);
+            } catch (final JsonParseException e) {
+                Log.d(Config.LOGTAG, "could not parse account keys", e);
+                return new Keys();
+            }
+        }
+
+        public Keys() {}
+
+        public Keys(final int deviceId) {
+            this.axolotlRegistrationId = deviceId;
+        }
+    }
+
     public enum State {
         DISABLED(false, false),
         LOGGED_OUT(false, false),
@@ -783,7 +798,6 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
                 case SEE_OTHER_HOST -> R.string.reconnect_on_other_host;
                 case MISSING_INTERNET_PERMISSION -> R.string.missing_internet_permission;
                 case TEMPORARY_AUTH_FAILURE -> R.string.account_status_temporary_auth_failure;
-                default -> R.string.account_status_unknown;
             };
         }
     }
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Conversation.java b/src/main/java/tel/xmpp/jabjab/entities/Conversation.java
index a4c2520..383c198 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Conversation.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Conversation.java
@@ -60,6 +60,8 @@ public class Conversation extends AbstractEntity
     public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS =
             "formerly_private_non_anonymous";
     public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
+    public static final String ATTRIBUTE_PINNED_MESSAGES = "pinned_messages";
+    private static final int MAX_PINNED_MESSAGES = 50;
     static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
     static final String ATTRIBUTE_CAPS2_HASH = "muc_caps2_hash";
     private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
@@ -1021,6 +1023,56 @@ public class Conversation extends AbstractEntity
         return builder.build();
     }
 
+    public List<String> getPinnedMessageUuids() {
+        final ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
+        synchronized (this.attributes) {
+            try {
+                final JSONArray array = this.attributes.getJSONArray(ATTRIBUTE_PINNED_MESSAGES);
+                for (int i = 0; i < array.length(); ++i) {
+                    builder.add(array.getString(i));
+                }
+            } catch (JSONException e) {
+                // ignored
+            }
+        }
+        return builder.build();
+    }
+
+    public boolean isPinned(final String uuid) {
+        return getPinnedMessageUuids().contains(uuid);
+    }
+
+    public void pinMessage(final String uuid) {
+        final List<String> current = new ArrayList<>(getPinnedMessageUuids());
+        current.remove(uuid);
+        current.add(0, uuid);
+        while (current.size() > MAX_PINNED_MESSAGES) {
+            current.remove(current.size() - 1);
+        }
+        setPinnedMessageUuids(current);
+    }
+
+    public void unpinMessage(final String uuid) {
+        final List<String> current = new ArrayList<>(getPinnedMessageUuids());
+        if (current.remove(uuid)) {
+            setPinnedMessageUuids(current);
+        }
+    }
+
+    private void setPinnedMessageUuids(final List<String> uuids) {
+        final JSONArray array = new JSONArray();
+        for (final String uuid : uuids) {
+            array.put(uuid);
+        }
+        synchronized (this.attributes) {
+            try {
+                this.attributes.put(ATTRIBUTE_PINNED_MESSAGES, array);
+            } catch (JSONException e) {
+                throw new AssertionError(e);
+            }
+        }
+    }
+
     private int getIntAttribute(String key, int defaultValue) {
         String value = this.getAttribute(key);
         if (value == null) {
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Edit.java b/src/main/java/tel/xmpp/jabjab/entities/Edit.java
index 7c0c94b..2707a03 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Edit.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Edit.java
@@ -1,32 +1,22 @@
 package tel.xmpp.jabjab.entities;
 
-import java.util.ArrayList;
+import android.util.Log;
+import com.google.common.base.Strings;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.SerializedName;
+import com.google.gson.reflect.TypeToken;
+import tel.xmpp.jabjab.Config;
+import im.conversations.android.json.Services;
+import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
 
-public class Edit {
+public record Edit(
+        @SerializedName("edited_id") String editedId,
+        @SerializedName("server_msg_id") String serverMsgId) {
 
-    private final String editedId;
-    private final String serverMsgId;
-
-    Edit(String editedId, String serverMsgId) {
-        this.editedId = editedId;
-        this.serverMsgId = serverMsgId;
-    }
-
-    static String toJson(List<Edit> edits) throws JSONException {
-        JSONArray jsonArray = new JSONArray();
-        for (Edit edit : edits) {
-            jsonArray.put(edit.toJson());
-        }
-        return jsonArray.toString();
-    }
-
-    static boolean wasPreviouslyEditedRemoteMsgId(List<Edit> edits, String remoteMsgId) {
-        for (Edit edit : edits) {
+    static boolean wasPreviouslyEditedRemoteMsgId(
+            final List<Edit> edits, final String remoteMsgId) {
+        for (final var edit : edits) {
             if (edit.editedId != null && edit.editedId.equals(remoteMsgId)) {
                 return true;
             }
@@ -34,8 +24,9 @@ public class Edit {
         return false;
     }
 
-    static boolean wasPreviouslyEditedServerMsgId(List<Edit> edits, String serverMsgId) {
-        for (Edit edit : edits) {
+    static boolean wasPreviouslyEditedServerMsgId(
+            final List<Edit> edits, final String serverMsgId) {
+        for (final var edit : edits) {
             if (edit.serverMsgId != null && edit.serverMsgId.equals(serverMsgId)) {
                 return true;
             }
@@ -43,59 +34,15 @@ public class Edit {
         return false;
     }
 
-    private static Edit fromJson(JSONObject jsonObject) throws JSONException {
-        String edited = jsonObject.has("edited_id") ? jsonObject.getString("edited_id") : null;
-        String serverMsgId =
-                jsonObject.has("server_msg_id") ? jsonObject.getString("server_msg_id") : null;
-        return new Edit(edited, serverMsgId);
-    }
-
-    static List<Edit> fromJson(String input) {
-        final ArrayList<Edit> list = new ArrayList<>();
-        if (input == null) {
-            return list;
+    public static List<Edit> ofString(final String string) {
+        if (Strings.isNullOrEmpty(string)) {
+            return Collections.emptyList();
         }
         try {
-            final JSONArray jsonArray = new JSONArray(input);
-            for (int i = 0; i < jsonArray.length(); ++i) {
-                list.add(fromJson(jsonArray.getJSONObject(i)));
-            }
-            return list;
-        } catch (JSONException e) {
-            return list;
+            return Services.GSON.fromJson(string, new TypeToken<List<Edit>>() {}.getType());
+        } catch (final JsonSyntaxException e) {
+            Log.w(Config.LOGTAG, "could not parse edits");
+            return Collections.emptyList();
         }
     }
-
-    private JSONObject toJson() throws JSONException {
-        JSONObject jsonObject = new JSONObject();
-        jsonObject.put("edited_id", editedId);
-        jsonObject.put("server_msg_id", serverMsgId);
-        return jsonObject;
-    }
-
-    String getEditedId() {
-        return editedId;
-    }
-
-    public String getServerMsgId() {
-        return this.serverMsgId;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        if (this == o) return true;
-        if (o == null || getClass() != o.getClass()) return false;
-
-        Edit edit = (Edit) o;
-
-        if (!Objects.equals(editedId, edit.editedId)) return false;
-        return Objects.equals(serverMsgId, edit.serverMsgId);
-    }
-
-    @Override
-    public int hashCode() {
-        int result = editedId != null ? editedId.hashCode() : 0;
-        result = 31 * result + (serverMsgId != null ? serverMsgId.hashCode() : 0);
-        return result;
-    }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java b/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java
index 88fb931..ed9500a 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java
@@ -59,7 +59,7 @@ public class IndividualMessage extends Message {
             String serverMsgId,
             String fingerprint,
             boolean read,
-            String edited,
+            Collection<Edit> edited,
             boolean oob,
             String errorMessage,
             Set<ReadByMarker> readByMarkers,
@@ -162,7 +162,7 @@ public class IndividualMessage extends Message {
                 cursor.getString(cursor.getColumnIndexOrThrow(SERVER_MSG_ID)),
                 cursor.getString(cursor.getColumnIndexOrThrow(FINGERPRINT)),
                 cursor.getInt(cursor.getColumnIndexOrThrow(READ)) > 0,
-                cursor.getString(cursor.getColumnIndexOrThrow(EDITED)),
+                Edit.ofString(cursor.getString(cursor.getColumnIndexOrThrow(EDITED))),
                 cursor.getInt(cursor.getColumnIndexOrThrow(OOB)) > 0,
                 cursor.getString(cursor.getColumnIndexOrThrow(ERROR_MESSAGE)),
                 ReadByMarker.fromJsonString(
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Message.java b/src/main/java/tel/xmpp/jabjab/entities/Message.java
index ab29e07..4814c2f 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Message.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Message.java
@@ -4,7 +4,6 @@ import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.graphics.Color;
-import android.util.Log;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
@@ -25,15 +24,17 @@ import tel.xmpp.jabjab.utils.MessageUtils;
 import tel.xmpp.jabjab.utils.MimeUtils;
 import tel.xmpp.jabjab.utils.UIHelper;
 import tel.xmpp.jabjab.xmpp.Jid;
+import im.conversations.android.json.Services;
 import java.io.File;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArraySet;
-import org.json.JSONException;
+import org.jspecify.annotations.NonNull;
 
 public class Message extends AbstractEntity
         implements AvatarService.Avatar, MucOptions.IdentifiableUser {
@@ -122,7 +123,7 @@ public class Message extends AbstractEntity
     private Message mPreviousMessage = null;
     private String axolotlFingerprint = null;
     private String errorMessage = null;
-    private Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
+    private final Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
     private String occupantId;
     private Collection<Reaction> reactions = Collections.emptyList();
     private String stickerPackNode = null;
@@ -167,10 +168,10 @@ public class Message extends AbstractEntity
                 null,
                 null,
                 true,
-                null,
+                Collections.emptyList(),
                 false,
                 null,
-                null,
+                Collections.emptySet(),
                 false,
                 false,
                 null,
@@ -178,7 +179,11 @@ public class Message extends AbstractEntity
                 Collections.emptyList());
     }
 
-    public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
+    public Message(
+            final Conversation conversation,
+            final int status,
+            final int type,
+            final String remoteMsgId) {
         this(
                 conversation,
                 java.util.UUID.randomUUID().toString(),
@@ -196,10 +201,10 @@ public class Message extends AbstractEntity
                 null,
                 null,
                 true,
-                null,
+                Collections.emptyList(),
                 false,
                 null,
-                null,
+                Collections.emptySet(),
                 false,
                 false,
                 null,
@@ -224,7 +229,7 @@ public class Message extends AbstractEntity
             final String serverMsgId,
             final String fingerprint,
             final boolean read,
-            final String edited,
+            @NonNull final Collection<Edit> edited,
             final boolean oob,
             final String errorMessage,
             final Set<ReadByMarker> readByMarkers,
@@ -232,7 +237,7 @@ public class Message extends AbstractEntity
             final boolean deleted,
             final String bodyLanguage,
             final String occupantId,
-            final Collection<Reaction> reactions) {
+            @NonNull final Collection<Reaction> reactions) {
         this.conversation = conversation;
         this.uuid = uuid;
         this.conversationUuid = conversationUUid;
@@ -249,10 +254,10 @@ public class Message extends AbstractEntity
         this.serverMsgId = serverMsgId;
         this.axolotlFingerprint = fingerprint;
         this.read = read;
-        this.edits = Edit.fromJson(edited);
+        this.edits = new ArrayList<>(edited);
         this.oob = oob;
         this.errorMessage = errorMessage;
-        this.readByMarkers = readByMarkers == null ? new CopyOnWriteArraySet<>() : readByMarkers;
+        this.readByMarkers.addAll(readByMarkers);
         this.markable = markable;
         this.deleted = deleted;
         this.bodyLanguage = bodyLanguage;
@@ -279,7 +284,7 @@ public class Message extends AbstractEntity
                 cursor.getString(cursor.getColumnIndexOrThrow(SERVER_MSG_ID)),
                 cursor.getString(cursor.getColumnIndexOrThrow(FINGERPRINT)),
                 cursor.getInt(cursor.getColumnIndexOrThrow(READ)) > 0,
-                cursor.getString(cursor.getColumnIndexOrThrow(EDITED)),
+                Edit.ofString(cursor.getString(cursor.getColumnIndexOrThrow(EDITED))),
                 cursor.getInt(cursor.getColumnIndexOrThrow(OOB)) > 0,
                 cursor.getString(cursor.getColumnIndexOrThrow(ERROR_MESSAGE)),
                 ReadByMarker.fromJsonString(
@@ -379,14 +384,10 @@ public class Message extends AbstractEntity
         values.put(SERVER_MSG_ID, serverMsgId);
         values.put(FINGERPRINT, axolotlFingerprint);
         values.put(READ, read ? 1 : 0);
-        try {
-            values.put(EDITED, Edit.toJson(edits));
-        } catch (JSONException e) {
-            Log.e(Config.LOGTAG, "error persisting json for edits", e);
-        }
+        values.put(EDITED, Services.GSON.toJson(edits));
         values.put(OOB, oob ? 1 : 0);
         values.put(ERROR_MESSAGE, errorMessage);
-        values.put(READ_BY_MARKERS, ReadByMarker.toJson(readByMarkers).toString());
+        values.put(READ_BY_MARKERS, Services.GSON.toJson(readByMarkers));
         values.put(MARKABLE, markable ? 1 : 0);
         values.put(DELETED, deleted ? 1 : 0);
         values.put(BODY_LANGUAGE, bodyLanguage);
@@ -597,22 +598,22 @@ public class Message extends AbstractEntity
     }
 
     public boolean addReadByMarker(final ReadByMarker readByMarker) {
-        if (readByMarker.getRealJid() != null) {
-            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
+        if (readByMarker.realJid() != null) {
+            if (readByMarker.realJid().asBareJid().equals(trueCounterpart)) {
                 return false;
             }
-        } else if (readByMarker.getFullJid() != null) {
-            if (readByMarker.getFullJid().equals(counterpart)) {
+        } else if (readByMarker.fullJid() != null) {
+            if (readByMarker.fullJid().equals(counterpart)) {
                 return false;
             }
         }
         if (this.readByMarkers.add(readByMarker)) {
-            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
+            if (readByMarker.realJid() != null && readByMarker.fullJid() != null) {
                 Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
                 while (iterator.hasNext()) {
                     ReadByMarker marker = iterator.next();
-                    if (marker.getRealJid() == null
-                            && readByMarker.getFullJid().equals(marker.getFullJid())) {
+                    if (marker.realJid() == null
+                            && readByMarker.fullJid().equals(marker.fullJid())) {
                         iterator.remove();
                     }
                 }
@@ -630,8 +631,10 @@ public class Message extends AbstractEntity
     public Set<Jid> getReadyByTrue() {
         return ImmutableSet.copyOf(
                 Collections2.transform(
-                        Collections2.filter(this.readByMarkers, m -> m.getRealJid() != null),
-                        ReadByMarker::getRealJid));
+                        Collections2.filter(
+                                this.readByMarkers,
+                                m -> Objects.requireNonNull(m).realJid() != null),
+                        r -> Objects.requireNonNull(r).realJid()));
     }
 
     boolean similar(Message message) {
@@ -849,18 +852,18 @@ public class Message extends AbstractEntity
         if (this.edits.isEmpty()) {
             throw new IllegalStateException("Attempting to access unedited message");
         }
-        return edits.get(edits.size() - 1).getEditedId();
+        return edits.get(edits.size() - 1).editedId();
     }
 
     public Collection<String> getEditedServerMessageIds() {
-        return Collections2.transform(this.edits, Edit::getServerMsgId);
+        return Collections2.transform(this.edits, Edit::serverMsgId);
     }
 
     public String getEditedIdWireFormat() {
         if (this.edits.isEmpty()) {
             throw new IllegalStateException("Attempting to access unedited message");
         }
-        return edits.get(0).getEditedId();
+        return edits.get(0).editedId();
     }
 
     public void setOob(boolean isOob) {
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Reaction.java b/src/main/java/tel/xmpp/jabjab/entities/Reaction.java
index 706e5cc..e1fada3 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Reaction.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Reaction.java
@@ -12,18 +12,12 @@ import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.gson.JsonSyntaxException;
-import com.google.gson.TypeAdapter;
 import com.google.gson.reflect.TypeToken;
-import com.google.gson.stream.JsonReader;
-import com.google.gson.stream.JsonToken;
-import com.google.gson.stream.JsonWriter;
+import im.conversations.android.json.Services;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.utils.Emoticons;
 import tel.xmpp.jabjab.xmpp.Jid;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -51,12 +45,6 @@ public class Reaction implements MucOptions.IdentifiableUser {
     private static final int MAX_RECENT = 20;
     private static final int SHOW_COUNT = 18;
 
-    private static final Gson GSON;
-
-    static {
-        GSON = new GsonBuilder().registerTypeAdapter(Jid.class, new JidTypeAdapter()).create();
-    }
-
     public static List<String> getRecentSuggestions(final Context context) {
         final var prefs = context.getSharedPreferences(REACTION_PREFS, Context.MODE_PRIVATE);
         final var recent = prefs.getString(KEY_RECENT, "");
@@ -114,7 +102,7 @@ public class Reaction implements MucOptions.IdentifiableUser {
     }
 
     public static String toString(final Collection<Reaction> reactions) {
-        return (reactions == null || reactions.isEmpty()) ? null : GSON.toJson(reactions);
+        return (reactions == null || reactions.isEmpty()) ? null : Services.GSON.toJson(reactions);
     }
 
     public static Collection<Reaction> fromString(final String asString) {
@@ -122,7 +110,7 @@ public class Reaction implements MucOptions.IdentifiableUser {
             return Collections.emptyList();
         }
         try {
-            return GSON.fromJson(asString, new TypeToken<List<Reaction>>() {}.getType());
+            return Services.GSON.fromJson(asString, new TypeToken<List<Reaction>>() {}.getType());
         } catch (final IllegalArgumentException | JsonSyntaxException e) {
             Log.e(Config.LOGTAG, "could not restore reactions", e);
             return Collections.emptyList();
@@ -187,29 +175,6 @@ public class Reaction implements MucOptions.IdentifiableUser {
         return this.occupantId;
     }
 
-    private static class JidTypeAdapter extends TypeAdapter<Jid> {
-        @Override
-        public void write(final JsonWriter out, final Jid value) throws IOException {
-            if (value == null) {
-                out.nullValue();
-            } else {
-                out.value(value.toString());
-            }
-        }
-
-        @Override
-        public Jid read(final JsonReader in) throws IOException {
-            if (in.peek() == JsonToken.NULL) {
-                in.nextNull();
-                return null;
-            } else if (in.peek() == JsonToken.STRING) {
-                final String value = in.nextString();
-                return Jid.of(value);
-            }
-            throw new IOException("Unexpected token");
-        }
-    }
-
     public static Aggregated aggregated(final Collection<Reaction> reactions) {
         final Map<String, Integer> aggregatedReactions =
                 Maps.transformValues(
diff --git a/src/main/java/tel/xmpp/jabjab/entities/ReadByMarker.java b/src/main/java/tel/xmpp/jabjab/entities/ReadByMarker.java
index b779199..3df43bd 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/ReadByMarker.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/ReadByMarker.java
@@ -1,100 +1,33 @@
 package tel.xmpp.jabjab.entities;
 
+import android.util.Log;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.SerializedName;
+import com.google.gson.reflect.TypeToken;
+import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.xmpp.Jid;
+import im.conversations.android.json.Services;
 import java.util.Collection;
-import java.util.Objects;
+import java.util.Collections;
+import java.util.List;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArraySet;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
 
-public class ReadByMarker implements MucOptions.IdentifiableUser {
-
-    private ReadByMarker() {}
-
-    @Override
-    public boolean equals(Object o) {
-        if (this == o) return true;
-        if (o == null || getClass() != o.getClass()) return false;
-
-        ReadByMarker marker = (ReadByMarker) o;
-
-        if (!Objects.equals(fullJid, marker.fullJid)) return false;
-        return Objects.equals(realJid, marker.realJid);
-    }
-
-    @Override
-    public int hashCode() {
-        int result = fullJid != null ? fullJid.hashCode() : 0;
-        result = 31 * result + (realJid != null ? realJid.hashCode() : 0);
-        return result;
-    }
-
-    private Jid fullJid;
-    private Jid realJid;
-    private String occupantId;
-
-    public Jid getFullJid() {
-        return fullJid;
-    }
-
-    public Jid getRealJid() {
-        return realJid;
-    }
-
-    public JSONObject toJson() {
-        JSONObject jsonObject = new JSONObject();
-        if (fullJid != null) {
-            try {
-                jsonObject.put("fullJid", fullJid.toString());
-            } catch (JSONException e) {
-                // ignore
-            }
-        }
-        if (realJid != null) {
-            try {
-                jsonObject.put("realJid", realJid.toString());
-            } catch (JSONException e) {
-                // ignore
-            }
-        }
-        if (occupantId != null) {
-            try {
-                jsonObject.put("occupantId", occupantId);
-            } catch (JSONException e) {
-                // ignore
-            }
-        }
-        return jsonObject;
-    }
-
-    public static Set<ReadByMarker> fromJson(final JSONArray jsonArray) {
-        final Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
-        for (int i = 0; i < jsonArray.length(); ++i) {
-            try {
-                readByMarkers.add(fromJson(jsonArray.getJSONObject(i)));
-            } catch (JSONException e) {
-                // ignored
-            }
-        }
-        return readByMarkers;
-    }
+public record ReadByMarker(
+        @SerializedName("occupantId") String occupantId,
+        @SerializedName("fullJid") Jid fullJid,
+        @SerializedName("realJid") Jid realJid)
+        implements MucOptions.IdentifiableUser {
 
     public static ReadByMarker from(final Message message) {
-        final ReadByMarker marker = new ReadByMarker();
-        marker.occupantId = message.getOccupantId();
-        marker.fullJid = message.getCounterpart();
-        marker.realJid = message.getTrueCounterpart();
-        return marker;
+        return new ReadByMarker(
+                message.getOccupantId(), message.getCounterpart(), message.getTrueCounterpart());
     }
 
     public static ReadByMarker from(final MucOptions.User user) {
-        final ReadByMarker marker = new ReadByMarker();
-        marker.occupantId = user.getOccupantId();
-        marker.fullJid = user.getFullJid();
-        marker.realJid = user.getRealJid();
-        return marker;
+        return new ReadByMarker(user.getOccupantId(), user.getFullJid(), user.getRealJid());
     }
 
     public static Set<ReadByMarker> from(Collection<MucOptions.User> users) {
@@ -105,42 +38,19 @@ public class ReadByMarker implements MucOptions.IdentifiableUser {
         return markers;
     }
 
-    public static ReadByMarker fromJson(JSONObject jsonObject) {
-        ReadByMarker marker = new ReadByMarker();
-        try {
-            marker.fullJid = Jid.of(jsonObject.getString("fullJid"));
-        } catch (JSONException | IllegalArgumentException e) {
-            marker.fullJid = null;
-        }
-        try {
-            marker.realJid = Jid.of(jsonObject.getString("realJid"));
-        } catch (JSONException | IllegalArgumentException e) {
-            marker.realJid = null;
-        }
-
-        try {
-            marker.occupantId = jsonObject.getString("occupantId");
-        } catch (JSONException | IllegalArgumentException e) {
-            marker.occupantId = null;
+    public static Set<ReadByMarker> fromJsonString(final String json) {
+        if (Strings.isNullOrEmpty(json)) {
+            return Collections.emptySet();
         }
-
-        return marker;
-    }
-
-    public static Set<ReadByMarker> fromJsonString(String json) {
+        final List<ReadByMarker> markers;
         try {
-            return fromJson(new JSONArray(json));
-        } catch (final JSONException | NullPointerException e) {
-            return new CopyOnWriteArraySet<>();
+            markers =
+                    Services.GSON.fromJson(json, new TypeToken<List<ReadByMarker>>() {}.getType());
+        } catch (final JsonSyntaxException e) {
+            Log.e(Config.LOGTAG, "could not parse read marker from json", e);
+            return Collections.emptySet();
         }
-    }
-
-    public static JSONArray toJson(final Set<ReadByMarker> readByMarkers) {
-        final JSONArray jsonArray = new JSONArray();
-        for (final ReadByMarker marker : readByMarkers) {
-            jsonArray.put(marker.toJson());
-        }
-        return jsonArray;
+        return ImmutableSet.copyOf(markers);
     }
 
     public static boolean contains(
@@ -164,8 +74,8 @@ public class ReadByMarker implements MucOptions.IdentifiableUser {
     }
 
     public static boolean allUsersRepresented(
-            Collection<MucOptions.User> users, Set<ReadByMarker> markers) {
-        for (MucOptions.User user : users) {
+            final Collection<MucOptions.User> users, final Set<ReadByMarker> markers) {
+        for (final var user : users) {
             if (!contains(from(user), markers)) {
                 return false;
             }
@@ -174,7 +84,9 @@ public class ReadByMarker implements MucOptions.IdentifiableUser {
     }
 
     public static boolean allUsersRepresented(
-            Collection<MucOptions.User> users, Set<ReadByMarker> markers, ReadByMarker marker) {
+            final Collection<MucOptions.User> users,
+            final Set<ReadByMarker> markers,
+            final ReadByMarker marker) {
         final Set<ReadByMarker> markersCopy = new CopyOnWriteArraySet<>(markers);
         markersCopy.add(marker);
         return allUsersRepresented(users, markersCopy);
diff --git a/src/main/java/tel/xmpp/jabjab/http/HttpConnectionManager.java b/src/main/java/tel/xmpp/jabjab/http/HttpConnectionManager.java
index 0e6b90d..5e5debb 100644
--- a/src/main/java/tel/xmpp/jabjab/http/HttpConnectionManager.java
+++ b/src/main/java/tel/xmpp/jabjab/http/HttpConnectionManager.java
@@ -75,7 +75,8 @@ public class HttpConnectionManager extends AbstractConnectionManager {
     }
 
     public static String getUserAgent() {
-        return String.format("%s/%s", BuildConfig.APP_NAME, BuildConfig.VERSION_NAME);
+        return String.format(
+                "%s/%s+%s", BuildConfig.APP_NAME, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE);
     }
 
     public HttpConnectionManager(XmppConnectionService service) {
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
index 5db5bfd..a7d1b3b 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
@@ -704,17 +704,15 @@ public class DatabaseBackend extends SQLiteOpenHelper {
          * depends on account de-serialization.
          */
         if (oldVersion < 17 && newVersion >= 17 && newVersion < 31) {
-            List<Account> accounts = getAccounts(db);
-            for (Account account : accounts) {
-                String ownDeviceIdString =
-                        account.getKey(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID);
-                if (ownDeviceIdString == null) {
+            final var accounts = getAccounts(db);
+            for (final var account : accounts) {
+                final var ownDeviceId = account.getAxolotlRegistrationId();
+                if (!ownDeviceId.isPresent()) {
                     continue;
                 }
-                int ownDeviceId = Integer.valueOf(ownDeviceIdString);
                 SignalProtocolAddress ownAddress =
                         new SignalProtocolAddress(
-                                account.getJid().asBareJid().toString(), ownDeviceId);
+                                account.getJid().asBareJid().toString(), ownDeviceId.get());
                 deleteSession(db, account, ownAddress);
                 IdentityKeyPair identityKeyPair = loadOwnIdentityKeyPair(db, account);
                 if (identityKeyPair != null) {
@@ -750,9 +748,9 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         }
 
         if (oldVersion < 21 && newVersion >= 21) {
-            List<Account> accounts = getAccounts(db);
-            for (Account account : accounts) {
-                account.unsetPgpSignature();
+            final var accounts = getAccounts(db);
+            for (final var account : accounts) {
+                account.resetPgp();
                 db.update(
                         Account.TABLENAME,
                         account.getContentValues(),
diff --git a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
index b4223f7..3ef9de7 100644
--- a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
@@ -1071,12 +1071,11 @@ public class XmppConnectionService extends Service {
     }
 
     public void fetchServiceOutageStatus(final Account account) {
-        final var sosUrl = account.getKey(Account.KEY_SOS_URL);
-        if (Strings.isNullOrEmpty(sosUrl)) {
-            return;
-        }
-        final var url = HttpUrl.parse(sosUrl);
-        if (url == null) {
+        final HttpUrl url;
+        final var sosUrl = account.getSosUrl();
+        if (sosUrl.isPresent()) {
+            url = sosUrl.get();
+        } else {
             return;
         }
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": fetching service outage " + url);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BotCommandExecutor.java b/src/main/java/tel/xmpp/jabjab/ui/BotCommandExecutor.java
new file mode 100644
index 0000000..fbb9c86
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/BotCommandExecutor.java
@@ -0,0 +1,286 @@
+package tel.xmpp.jabjab.ui;
+
+import android.util.Log;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import com.google.common.base.Strings;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.common.collect.Collections2;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import im.conversations.android.xmpp.model.data.Data;
+import im.conversations.android.xmpp.model.data.Field;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.entities.Account;
+import tel.xmpp.jabjab.xmpp.Jid;
+import tel.xmpp.jabjab.xmpp.manager.AdHocCommandsManager;
+
+/**
+ * Drives a XEP-0050 ad-hoc command from a UI entry point: execute, then either show the result
+ * (Completed), a form to fill in (Executing with fields), or a cancellation notice (Cancelled).
+ * Submitting a form re-enters the same dispatch, which is what lets a genuinely multi-stage
+ * command (several NEXT round-trips) work without any special-casing here.
+ */
+final class BotCommandExecutor {
+
+    static final String LOG_PREFIX = "ADHOC";
+
+    private BotCommandExecutor() {}
+
+    static void execute(
+            final Fragment host, final Account account, final Jid botJid, final String node) {
+        Log.d(Config.LOGTAG, LOG_PREFIX + " execute node=" + node + " target=" + botJid);
+        final var manager = account.getXmppConnection().getManager(AdHocCommandsManager.class);
+        dispatch(host, account, botJid, node, manager.command(botJid, node));
+    }
+
+    private static void submit(
+            final Fragment host,
+            final Account account,
+            final Jid botJid,
+            final String node,
+            final AdHocCommandsManager.Executing executing,
+            final Map<String, Object> values) {
+        final var manager = account.getXmppConnection().getManager(AdHocCommandsManager.class);
+        dispatch(
+                host,
+                account,
+                botJid,
+                node,
+                manager.command(
+                        botJid, node, executing.execute(), executing.sessionId(), values));
+    }
+
+    private static void dispatch(
+            final Fragment host,
+            final Account account,
+            final Jid botJid,
+            final String node,
+            final com.google.common.util.concurrent.ListenableFuture<AdHocCommandsManager.Stage>
+                    future) {
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final AdHocCommandsManager.Stage stage) {
+                        Log.d(
+                                Config.LOGTAG,
+                                LOG_PREFIX
+                                        + " stage="
+                                        + stage.getClass().getSimpleName()
+                                        + " node="
+                                        + node
+                                        + " sessionId="
+                                        + stage.sessionId());
+                        runOnUiThreadIfAdded(
+                                host,
+                                () -> {
+                                    if (stage instanceof AdHocCommandsManager.Completed completed) {
+                                        logData(node, "Completed", completed.data());
+                                        showResult(host, completed.data());
+                                    } else if (stage
+                                            instanceof AdHocCommandsManager.Cancelled) {
+                                        Toast.makeText(
+                                                        host.requireContext(),
+                                                        R.string.command_cancelled,
+                                                        Toast.LENGTH_SHORT)
+                                                .show();
+                                    } else if (stage
+                                            instanceof AdHocCommandsManager.Executing executing) {
+                                        logData(node, "Executing", executing.data());
+                                        onExecuting(host, account, botJid, node, executing);
+                                    }
+                                });
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                LOG_PREFIX + " command " + node + " on " + botJid + " failed",
+                                throwable);
+                        runOnUiThreadIfAdded(
+                                host,
+                                () ->
+                                        Toast.makeText(
+                                                        host.requireContext(),
+                                                        R.string.commands_load_failed,
+                                                        Toast.LENGTH_SHORT)
+                                                .show());
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private static void onExecuting(
+            final Fragment host,
+            final Account account,
+            final Jid botJid,
+            final String node,
+            final AdHocCommandsManager.Executing executing) {
+        final var data = executing.data();
+        final var fields = data == null ? null : data.getFields();
+        final var hiddenValues = collectHiddenValues(fields);
+        if (fields == null || fields.isEmpty() || !hasRequiredInput(fields)) {
+            // Nothing the bot marked required — run immediately instead of making every
+            // command (ping, help, ...) wait on an empty dialog. Some bots (e.g. data@xmpp.tel)
+            // use one generic optional "args" field for every command regardless of whether
+            // that command is actually meaningless without it — for those, running blank just
+            // gets you back the bot's own usage hint as a normal chat reply, which is fine now
+            // that a no-op completion no longer shows a confusing "no result" dialog.
+            submit(host, account, botJid, node, executing, hiddenValues);
+            return;
+        }
+        BotCommandFormDialog.show(
+                host,
+                fields,
+                values -> {
+                    final Map<String, Object> merged = new LinkedHashMap<>(hiddenValues);
+                    merged.putAll(values);
+                    submit(host, account, botJid, node, executing, merged);
+                });
+    }
+
+    private static void logData(final String node, final String stageName, final Data data) {
+        if (data == null) {
+            Log.d(Config.LOGTAG, LOG_PREFIX + " " + stageName + " node=" + node + " data=null");
+            return;
+        }
+        Log.d(
+                Config.LOGTAG,
+                LOG_PREFIX
+                        + " "
+                        + stageName
+                        + " node="
+                        + node
+                        + " formType="
+                        + data.getFormType()
+                        + " fieldCount="
+                        + data.getFields().size());
+        for (final Field field : data.getFields()) {
+            Log.d(
+                    Config.LOGTAG,
+                    LOG_PREFIX
+                            + "   field var="
+                            + field.getFieldName()
+                            + " type="
+                            + field.getType()
+                            + " label="
+                            + field.getLabel()
+                            + " required="
+                            + field.isRequired()
+                            + " value="
+                            + field.getValue());
+        }
+        final var items = data.getExtensions(im.conversations.android.xmpp.model.data.Item.class);
+        Log.d(Config.LOGTAG, LOG_PREFIX + "   itemCount=" + items.size());
+    }
+
+    private static boolean hasRequiredInput(final java.util.Collection<Field> fields) {
+        for (final Field field : fields) {
+            final var type = field.getType();
+            if (type == Field.Type.FIXED || type == Field.Type.HIDDEN) {
+                continue;
+            }
+            if (field.isRequired()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static Map<String, Object> collectHiddenValues(
+            @androidx.annotation.Nullable final java.util.Collection<Field> fields) {
+        final Map<String, Object> hiddenValues = new LinkedHashMap<>();
+        if (fields == null) {
+            return hiddenValues;
+        }
+        for (final Field field : fields) {
+            if (field.getType() == Field.Type.HIDDEN) {
+                final var name = field.getFieldName();
+                if (name != null) {
+                    hiddenValues.put(name, field.getValue());
+                }
+            }
+        }
+        return hiddenValues;
+    }
+
+    private static void showResult(final Fragment host, final Data data) {
+        final var message = data == null ? "" : renderResult(data);
+        if (Strings.isNullOrEmpty(message)) {
+            // Ran fine, just nothing to display (a lot of action-style commands don't return
+            // any data) — a quiet toast beats a dialog with misleading "empty" wording.
+            Toast.makeText(host.requireContext(), R.string.command_completed, Toast.LENGTH_SHORT)
+                    .show();
+            return;
+        }
+        new MaterialAlertDialogBuilder(host.requireContext())
+                .setTitle(R.string.command_result)
+                .setMessage(message)
+                .setPositiveButton(android.R.string.ok, null)
+                .show();
+    }
+
+    /**
+     * Renders either a plain field/value form, or a XEP-0004 "result" form (a <reported> column
+     * list plus one <item> per row — used by list/report-style commands) as one line per row.
+     * Bots commonly answer with the latter for anything list-shaped, which the earlier version
+     * of this method never read at all, always falling through to "no result" for those.
+     */
+    private static String renderResult(final Data data) {
+        final var items =
+                data.getExtensions(im.conversations.android.xmpp.model.data.Item.class);
+        if (!items.isEmpty()) {
+            final var builder = new StringBuilder();
+            for (final var item : items) {
+                final var row = renderFields(item.getExtensions(Field.class));
+                if (!row.isEmpty()) {
+                    if (builder.length() > 0) {
+                        builder.append('\n');
+                    }
+                    builder.append(row);
+                }
+            }
+            return builder.toString();
+        }
+        return renderFields(data.getFields());
+    }
+
+    private static String renderFields(final java.util.Collection<Field> fields) {
+        final var builder = new StringBuilder();
+        for (final Field field :
+                Collections2.filter(fields, f -> f.getType() != Field.Type.HIDDEN)) {
+            final var label =
+                    Strings.isNullOrEmpty(field.getLabel())
+                            ? field.getFieldName()
+                            : field.getLabel();
+            final var value = field.getValue();
+            if (!Strings.isNullOrEmpty(value)) {
+                if (builder.length() > 0) {
+                    builder.append('\n');
+                }
+                builder.append(label).append(": ").append(value);
+            }
+        }
+        return builder.toString();
+    }
+
+    private static void runOnUiThreadIfAdded(final Fragment host, final Runnable body) {
+        final var activity = host.getActivity();
+        if (activity == null || !host.isAdded()) {
+            return;
+        }
+        activity.runOnUiThread(
+                () -> {
+                    if (host.isAdded()) {
+                        body.run();
+                    }
+                });
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BotCommandFormDialog.java b/src/main/java/tel/xmpp/jabjab/ui/BotCommandFormDialog.java
new file mode 100644
index 0000000..342f4a4
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/BotCommandFormDialog.java
@@ -0,0 +1,267 @@
+package tel.xmpp.jabjab.ui;
+
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.materialswitch.MaterialSwitch;
+import com.google.android.material.textfield.TextInputEditText;
+import com.google.common.base.Strings;
+import im.conversations.android.xmpp.model.data.Field;
+import im.conversations.android.xmpp.model.data.Option;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import tel.xmpp.jabjab.R;
+
+/** Renders a XEP-0004 data form (a subset of field types) as a dialog and collects the answers. */
+public class BotCommandFormDialog extends DialogFragment {
+
+    private static transient Collection<Field> pendingFields;
+    private static transient Consumer<Map<String, Object>> pendingCallback;
+
+    private final List<FieldRow> rows = new ArrayList<>();
+    private Consumer<Map<String, Object>> callback;
+
+    static void show(
+            final Fragment host,
+            final Collection<Field> fields,
+            final Consumer<Map<String, Object>> onSubmit) {
+        // Field/Consumer aren't Parcelable/Serializable in a way we can put in fragment
+        // arguments, and this dialog is only ever shown synchronously right after being
+        // constructed, so stash them statically for the single dialog instance to pick up.
+        pendingFields = fields;
+        pendingCallback = onSubmit;
+        new BotCommandFormDialog().show(host.getChildFragmentManager(), "bot_command_form");
+    }
+
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
+        final var fields = pendingFields;
+        this.callback = pendingCallback;
+        pendingFields = null;
+        pendingCallback = null;
+
+        final var inflater = LayoutInflater.from(requireContext());
+        final var root =
+                inflater.inflate(R.layout.dialog_command_form, null, false);
+        final LinearLayout fieldsContainer = root.findViewById(R.id.command_form_fields);
+
+        if (fields != null) {
+            for (final Field field : fields) {
+                final var row = buildRow(inflater, fieldsContainer, field);
+                if (row != null) {
+                    this.rows.add(row);
+                }
+            }
+        }
+
+        // Positive button click handling is overridden in onStart() below so a required-field
+        // validation failure can keep the dialog open instead of dismissing on tap — setting
+        // a listener here would always dismiss regardless of what it does.
+        return new MaterialAlertDialogBuilder(requireContext())
+                .setTitle(R.string.tab_commands)
+                .setView(root)
+                .setPositiveButton(android.R.string.ok, null)
+                .setNegativeButton(android.R.string.cancel, null)
+                .create();
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        final var dialog = getDialog();
+        if (!(dialog instanceof AlertDialog alertDialog)) {
+            return;
+        }
+        final var positiveButton = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE);
+        if (positiveButton == null) {
+            return;
+        }
+        positiveButton.setOnClickListener(
+                v -> {
+                    if (!validate()) {
+                        return;
+                    }
+                    if (this.callback != null) {
+                        this.callback.accept(collectValues());
+                    }
+                    dismiss();
+                });
+    }
+
+    private boolean validate() {
+        boolean valid = true;
+        for (final FieldRow row : this.rows) {
+            if (!row.validate()) {
+                valid = false;
+            }
+        }
+        return valid;
+    }
+
+    @Nullable
+    private FieldRow buildRow(
+            final LayoutInflater inflater, final LinearLayout container, final Field field) {
+        final var type = field.getType();
+        final var name = field.getFieldName();
+        if (name == null || type == Field.Type.FIXED || type == Field.Type.HIDDEN) {
+            return null;
+        }
+        final var required = field.isRequired();
+        final var baseLabel = Strings.isNullOrEmpty(field.getLabel()) ? name : field.getLabel();
+        final var label = required ? baseLabel + " *" : baseLabel;
+
+        if (type == Field.Type.BOOLEAN) {
+            final var row =
+                    (MaterialSwitch)
+                            inflater.inflate(R.layout.item_command_form_boolean, container, false);
+            row.setText(label);
+            row.setChecked(isTruthy(field.getValue()));
+            container.addView(row);
+            return new BooleanRow(name, row);
+        }
+
+        if (type == Field.Type.LIST_SINGLE) {
+            final var row = inflater.inflate(R.layout.item_command_form_list, container, false);
+            final TextView labelView = row.findViewById(R.id.form_field_label);
+            final Spinner spinner = row.findViewById(R.id.form_field_spinner);
+            labelView.setText(label);
+            final var options = field.getExtensions(Option.class);
+            final List<String> labels = new ArrayList<>();
+            final List<String> values = new ArrayList<>();
+            int selected = 0;
+            final var currentValue = field.getValue();
+            for (final Option option : options) {
+                final var optionValue = option.getValue();
+                labels.add(Strings.isNullOrEmpty(option.getLabel()) ? optionValue : option.getLabel());
+                values.add(optionValue);
+                if (optionValue != null && optionValue.equals(currentValue)) {
+                    selected = values.size() - 1;
+                }
+            }
+            spinner.setAdapter(
+                    new ArrayAdapter<>(
+                            requireContext(), android.R.layout.simple_spinner_dropdown_item, labels));
+            if (!values.isEmpty()) {
+                spinner.setSelection(selected);
+            }
+            container.addView(row);
+            return new ListRow(name, spinner, values, required);
+        }
+
+        // TEXT_SINGLE, TEXT_PRIVATE, TEXT_MULTI, JID_SINGLE, JID_MULTI, LIST_MULTI (comma-
+        // separated fallback for the two multi-value types, documented as a scope limitation).
+        final var row =
+                (com.google.android.material.textfield.TextInputLayout)
+                        inflater.inflate(R.layout.item_command_form_text, container, false);
+        row.setHint(label);
+        final TextInputEditText input = row.findViewById(R.id.form_field_input);
+        if (type == Field.Type.TEXT_PRIVATE) {
+            input.setInputType(
+                    android.text.InputType.TYPE_CLASS_TEXT
+                            | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD);
+        } else if (type == Field.Type.TEXT_MULTI) {
+            input.setInputType(
+                    android.text.InputType.TYPE_CLASS_TEXT
+                            | android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE);
+            input.setMinLines(2);
+        } else if (type == Field.Type.JID_MULTI || type == Field.Type.LIST_MULTI) {
+            row.setHelperText(getString(R.string.comma_separated_values));
+            input.setText(String.join(",", field.getValues()));
+        }
+        if (input.getText() == null || input.getText().length() == 0) {
+            input.setText(field.getValue());
+        }
+        container.addView(row);
+        return new TextRow(name, row, input, required);
+    }
+
+    private Map<String, Object> collectValues() {
+        final Map<String, Object> values = new LinkedHashMap<>();
+        for (final FieldRow row : this.rows) {
+            row.putInto(values);
+        }
+        return values;
+    }
+
+    private static boolean isTruthy(@Nullable final String value) {
+        return "1".equals(value) || "true".equalsIgnoreCase(value);
+    }
+
+    private interface FieldRow {
+        void putInto(Map<String, Object> values);
+
+        /** Returns false (and should surface an error to the user) if this row fails validation. */
+        boolean validate();
+    }
+
+    private record TextRow(
+            String name,
+            com.google.android.material.textfield.TextInputLayout inputLayout,
+            TextInputEditText input,
+            boolean required)
+            implements FieldRow {
+        @Override
+        public void putInto(final Map<String, Object> values) {
+            final var text = input.getText();
+            values.put(name, text == null ? "" : text.toString());
+        }
+
+        @Override
+        public boolean validate() {
+            if (!required || (input.getText() != null && input.getText().length() > 0)) {
+                inputLayout.setError(null);
+                return true;
+            }
+            inputLayout.setError(inputLayout.getContext().getString(R.string.field_required));
+            return false;
+        }
+    }
+
+    private record BooleanRow(String name, MaterialSwitch input) implements FieldRow {
+        @Override
+        public void putInto(final Map<String, Object> values) {
+            values.put(name, input.isChecked() ? "1" : "0");
+        }
+
+        @Override
+        public boolean validate() {
+            // A switch always has a definite value, nothing to enforce here.
+            return true;
+        }
+    }
+
+    private record ListRow(String name, Spinner input, List<String> values, boolean required)
+            implements FieldRow {
+        @Override
+        public void putInto(final Map<String, Object> values) {
+            final var position = input.getSelectedItemPosition();
+            if (position >= 0 && position < this.values.size()) {
+                values.put(name, this.values.get(position));
+            }
+        }
+
+        @Override
+        public boolean validate() {
+            // A populated spinner always has a selection; an empty options list is a
+            // malformed form on the bot's side, not something the user can fix here.
+            return true;
+        }
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
new file mode 100644
index 0000000..c913c95
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
@@ -0,0 +1,223 @@
+package tel.xmpp.jabjab.ui;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import com.google.common.collect.Collections2;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import im.conversations.android.xmpp.Entity;
+import im.conversations.android.xmpp.model.disco.items.Item;
+import java.util.ArrayList;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.entities.Account;
+import tel.xmpp.jabjab.entities.Conversation;
+import tel.xmpp.jabjab.ui.adapter.BotCommandAdapter;
+import tel.xmpp.jabjab.xml.Namespace;
+import tel.xmpp.jabjab.xmpp.Jid;
+import tel.xmpp.jabjab.xmpp.manager.DiscoManager;
+
+/** Lists the XEP-0050 ad-hoc commands a bot contact supports; tapping one executes it. */
+public class BotCommandsFragment extends Fragment {
+
+    public static final String TAG = "bot_commands_fragment";
+
+    private static final String ARG_ACCOUNT_UUID = "account_uuid";
+    private static final String ARG_BOT_JID = "bot_jid";
+
+    private RecyclerView commandsList;
+    private View commandsLoading;
+    private TextView commandsEmpty;
+    private BotCommandAdapter adapter;
+
+    public static BotCommandsFragment newInstance(final Conversation conversation) {
+        final var fragment = new BotCommandsFragment();
+        final var args = new Bundle();
+        args.putString(ARG_ACCOUNT_UUID, conversation.getAccount().getUuid());
+        args.putString(ARG_BOT_JID, conversation.getContact().getAddress().asBareJid().toString());
+        fragment.setArguments(args);
+        return fragment;
+    }
+
+    @Nullable
+    @Override
+    public View onCreateView(
+            @NonNull final LayoutInflater inflater,
+            @Nullable final ViewGroup container,
+            @Nullable final Bundle savedInstanceState) {
+        return inflater.inflate(R.layout.fragment_bot_commands, container, false);
+    }
+
+    @Override
+    public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
+        super.onViewCreated(view, savedInstanceState);
+        this.commandsList = view.findViewById(R.id.commands_list);
+        this.commandsLoading = view.findViewById(R.id.commands_loading);
+        this.commandsEmpty = view.findViewById(R.id.commands_empty);
+
+        this.commandsList.setLayoutManager(new LinearLayoutManager(requireContext()));
+        this.adapter = new BotCommandAdapter(this::onCommandClicked);
+        this.commandsList.setAdapter(this.adapter);
+
+        loadCommands();
+    }
+
+    private void onCommandClicked(final Item item) {
+        final var node = item.getNode();
+        if (node == null) {
+            return;
+        }
+        final var account = resolveAccount();
+        final var botJid = resolveTargetJid();
+        if (account == null || botJid == null) {
+            return;
+        }
+        // The bot's response arrives as a normal chat message, not inside this tab, so jump
+        // back to Chat right away — any form dialog still floats above it either way.
+        final var parent = getParentFragment();
+        if (parent instanceof ConversationFragment conversationFragment) {
+            conversationFragment.switchToChatTab();
+        }
+        BotCommandExecutor.execute(this, account, botJid, node);
+    }
+
+    private void loadCommands() {
+        final var account = resolveAccount();
+        final var botJid = resolveTargetJid();
+        if (account == null || botJid == null) {
+            showEmpty(getString(R.string.commands_load_failed));
+            return;
+        }
+        showLoading();
+        Log.d(Config.LOGTAG, BotCommandExecutor.LOG_PREFIX + " fetching commands from " + botJid);
+        final var discoManager = account.getXmppConnection().getManager(DiscoManager.class);
+        final var future = discoManager.items(Entity.discoItem(botJid), Namespace.COMMANDS);
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final java.util.Collection<Item> items) {
+                        Log.d(
+                                Config.LOGTAG,
+                                BotCommandExecutor.LOG_PREFIX
+                                        + " got "
+                                        + items.size()
+                                        + " command(s) from "
+                                        + botJid
+                                        + ": "
+                                        + Collections2.transform(items, Item::getNode));
+                        requireActivityIfAdded(
+                                activity -> {
+                                    final var list = new ArrayList<>(items);
+                                    if (list.isEmpty()) {
+                                        showEmpty(getString(R.string.commands_empty));
+                                    } else {
+                                        showList(list);
+                                    }
+                                });
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                BotCommandExecutor.LOG_PREFIX
+                                        + " could not load ad-hoc commands for "
+                                        + botJid,
+                                throwable);
+                        requireActivityIfAdded(
+                                activity -> showEmpty(getString(R.string.commands_load_failed)));
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void requireActivityIfAdded(final java.util.function.Consumer<Void> body) {
+        final var activity = getActivity();
+        if (activity == null || !isAdded()) {
+            return;
+        }
+        activity.runOnUiThread(
+                () -> {
+                    if (!isAdded() || getView() == null) {
+                        return;
+                    }
+                    body.accept(null);
+                });
+    }
+
+    private void showLoading() {
+        this.commandsLoading.setVisibility(View.VISIBLE);
+        this.commandsEmpty.setVisibility(View.GONE);
+        this.commandsList.setVisibility(View.GONE);
+    }
+
+    private void showEmpty(final String message) {
+        this.commandsLoading.setVisibility(View.GONE);
+        this.commandsList.setVisibility(View.GONE);
+        this.commandsEmpty.setText(message);
+        this.commandsEmpty.setVisibility(View.VISIBLE);
+    }
+
+    private void showList(final java.util.List<Item> items) {
+        this.commandsLoading.setVisibility(View.GONE);
+        this.commandsEmpty.setVisibility(View.GONE);
+        this.commandsList.setVisibility(View.VISIBLE);
+        this.adapter.submitList(items);
+    }
+
+    @Nullable
+    private Account resolveAccount() {
+        final var args = getArguments();
+        if (args == null) {
+            return null;
+        }
+        final var uuid = args.getString(ARG_ACCOUNT_UUID);
+        if (uuid == null) {
+            return null;
+        }
+        final var activity = (XmppActivity) getActivity();
+        if (activity == null) {
+            return null;
+        }
+        return activity.xmppConnectionService.findAccountByUuid(uuid);
+    }
+
+    @Nullable
+    private Jid resolveBotJid() {
+        final var args = getArguments();
+        if (args == null) {
+            return null;
+        }
+        final var jid = args.getString(ARG_BOT_JID);
+        return jid == null ? null : Jid.of(jid);
+    }
+
+    /**
+     * XEP-0050 ad-hoc commands are frequently only advertised by the bot's specific online
+     * resource (e.g. data@xmpp.tel/omnibot), not the bare JID — querying the bare JID gets
+     * item-not-found even though the bot is reachable. Prefer the contact's current full JID
+     * (from presence) and fall back to the bare JID only if we don't have one.
+     */
+    @Nullable
+    private Jid resolveTargetJid() {
+        final var account = resolveAccount();
+        final var bareJid = resolveBotJid();
+        if (account == null || bareJid == null) {
+            return bareJid;
+        }
+        final var contact = account.getRoster().getContact(bareJid);
+        final var fullAddresses = contact.getFullAddresses();
+        return fullAddresses.isEmpty() ? bareJid : fullAddresses.get(0);
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
index e2c7c2f..27d8bf1 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
@@ -563,6 +563,13 @@ public class ContactDetailsActivity extends OmemoActivity
         }
         binding.detailsContactXmppAddress.setText(
                 IrregularUnicodeDetector.style(this, contact.getAddress()));
+        final boolean isVerified =
+                tel.xmpp.jabjab.Config.VERIFIED_JIDS.contains(contact.getAddress().asBareJid());
+        binding.detailsVerifiedBadgeIcon.setVisibility(isVerified ? View.VISIBLE : View.GONE);
+        binding.detailsVerifiedBadgeIcon.setOnClickListener(
+                v ->
+                        Toast.makeText(this, R.string.contact_is_verified_toast, Toast.LENGTH_SHORT)
+                                .show());
         final String account = contact.getAccount().getJid().asBareJid().toString();
         binding.detailsAccount.setOnClickListener(this::onDetailsAccountClicked);
         binding.detailsAccount.setText(getString(R.string.using_account, account));
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index d89b141..ec4aff7 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -303,6 +303,7 @@ public class ConversationFragment extends XmppFragment
     private boolean mProgrammaticScroll = false;
     // Fingerprint of the last adapter content — skip notifyDataSetChanged when unchanged.
     private String lastAdapterFingerprint = "";
+    private int currentPinnedIndex = 0;
     private final android.os.Handler pinToBottomHandler = new android.os.Handler(android.os.Looper.getMainLooper());
     private final Runnable cancelPinToBottom = () -> pinToBottom = false;
     private final android.view.ViewTreeObserver.OnGlobalLayoutListener pinToBottomListener = this::onPinToBottomLayout;
@@ -753,6 +754,7 @@ public class ConversationFragment extends XmppFragment
                     final MenuItem menuOngoingCall = menu.findItem(R.id.action_ongoing_call);
                     final MenuItem menuVideoCall = menu.findItem(R.id.action_video_call);
                     final MenuItem menuTogglePinned = menu.findItem(R.id.action_toggle_pinned);
+                    final MenuItem menuPinnedMessages = menu.findItem(R.id.action_pinned_messages);
                     final var c = ConversationFragment.this.conversation;
                     if (c == null) {
                         return;
@@ -801,6 +803,7 @@ public class ConversationFragment extends XmppFragment
                     } else {
                         menuTogglePinned.setTitle(R.string.add_to_favorites);
                     }
+                    menuPinnedMessages.setVisible(!c.getPinnedMessageUuids().isEmpty());
                 }
 
                 @Override
@@ -857,6 +860,9 @@ public class ConversationFragment extends XmppFragment
                         case R.id.action_toggle_pinned:
                             togglePinned();
                             break;
+                        case R.id.action_pinned_messages:
+                            openPinnedMessagesList();
+                            break;
                         case R.id.action_set_ephemeral_timer:
                             showEphemeralTimerDialog(conversation);
                             break;
@@ -1546,6 +1552,15 @@ public class ConversationFragment extends XmppFragment
         binding.textSendButton.setOnClickListener(this.mSendButtonListener);
 
         binding.scrollToBottomButton.setOnClickListener(this.mScrollButtonListener);
+        binding.pinnedMessagesBanner.setOnClickListener(v -> onPinnedMessagesBannerTapped());
+        binding.pinnedMessagesBanner.setOnLongClickListener(
+                v -> {
+                    openPinnedMessagesList();
+                    return true;
+                });
+        binding.pinnedMessagesCycle.setOnClickListener(v -> cyclePinnedMessagesBanner(1));
+        binding.pinnedMessagesCyclePrev.setOnClickListener(v -> cyclePinnedMessagesBanner(-1));
+        binding.pinnedMessagesClose.setOnClickListener(v -> unpinCurrentBannerMessage());
         binding.messagesView.setOnScrollListener(mOnScrollListener);
         binding.messagesView.setTranscriptMode(ListView.TRANSCRIPT_MODE_NORMAL);
         binding.mediaPreview.setAdapter(mediaPreviewAdapter);
@@ -1574,9 +1589,91 @@ public class ConversationFragment extends XmppFragment
 
         this.binding.textInput.setCustomInsertionActionModeCallback(
                 new EditMessageActionModeCallback(this.binding.textInput));
+
+        binding.botCommandTabs.addOnTabSelectedListener(
+                new com.google.android.material.tabs.TabLayout.OnTabSelectedListener() {
+                    @Override
+                    public void onTabSelected(
+                            final com.google.android.material.tabs.TabLayout.Tab tab) {
+                        showBotCommandTab(tab.getPosition() == 1);
+                    }
+
+                    @Override
+                    public void onTabUnselected(
+                            final com.google.android.material.tabs.TabLayout.Tab tab) {}
+
+                    @Override
+                    public void onTabReselected(
+                            final com.google.android.material.tabs.TabLayout.Tab tab) {}
+                });
+
         return binding.getRoot();
     }
 
+    /**
+     * Toggles between the normal chat content and the bot-commands panel. Lazily attaches
+     * BotCommandsFragment the first time the Commands tab is selected for the currently open
+     * conversation so we don't re-fetch the command list on every tab switch.
+     */
+    private void showBotCommandTab(final boolean showCommands) {
+        if (this.binding == null) {
+            return;
+        }
+        this.binding.chatContentContainer.setVisibility(showCommands ? View.GONE : View.VISIBLE);
+        this.binding.botCommandsContainer.setVisibility(showCommands ? View.VISIBLE : View.GONE);
+        if (showCommands
+                && getChildFragmentManager().findFragmentByTag(BotCommandsFragment.TAG) == null) {
+            getChildFragmentManager()
+                    .beginTransaction()
+                    .replace(
+                            R.id.bot_commands_container,
+                            BotCommandsFragment.newInstance(this.conversation),
+                            BotCommandsFragment.TAG)
+                    .commit();
+        }
+    }
+
+    /**
+     * Called by BotCommandsFragment right when a command is dispatched — the bot's response
+     * arrives as a normal chat message, so jump back to the Chat tab so the user sees it land
+     * instead of staying on a Commands tab that never shows incoming messages.
+     */
+    void switchToChatTab() {
+        if (this.binding == null) {
+            return;
+        }
+        final var tab = this.binding.botCommandTabs.getTabAt(0);
+        if (tab != null && !tab.isSelected()) {
+            tab.select();
+        }
+    }
+
+    /**
+     * Shows/hides the Commands tab depending on whether the open conversation's counterpart is
+     * in Config.COMMAND_CAPABLE_JIDS, and resets to the Chat tab so switching between bot chats
+     * (or back to a non-bot chat) never leaves a stale Commands-tab state from fragment reuse.
+     */
+    private void updateBotCommandTabVisibility() {
+        if (this.binding == null || this.conversation == null) {
+            return;
+        }
+        final boolean isBotCommandCapable =
+                this.conversation.getMode() == Conversation.MODE_SINGLE
+                        && Config.COMMAND_CAPABLE_JIDS.contains(
+                                this.conversation.getContact().getAddress().asBareJid());
+        this.binding.botCommandTabs.setVisibility(isBotCommandCapable ? View.VISIBLE : View.GONE);
+        final var childFragmentManager = getChildFragmentManager();
+        final var existing = childFragmentManager.findFragmentByTag(BotCommandsFragment.TAG);
+        if (existing != null) {
+            childFragmentManager.beginTransaction().remove(existing).commit();
+        }
+        final var tab = this.binding.botCommandTabs.getTabAt(0);
+        if (tab != null) {
+            tab.select();
+        }
+        showBotCommandTab(false);
+    }
+
     private void toggleAttachmentChoicesVisibility() {
         setAttachmentChoicesVisibility(this.binding.attachmentChoices.getVisibility() == View.GONE);
     }
@@ -2018,6 +2115,8 @@ public class ConversationFragment extends XmppFragment
             final MenuItem saveToStickers = menu.findItem(R.id.save_to_stickers);
             final MenuItem saveGifToFavorites = menu.findItem(R.id.save_gif_to_favorites);
             final MenuItem installStickerPack = menu.findItem(R.id.action_install_sticker_pack);
+            final MenuItem pinMessage = menu.findItem(R.id.action_pin_message);
+            final MenuItem unpinMessage = menu.findItem(R.id.action_unpin_message);
             final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m);
             final boolean showError =
                     m.getStatus() == Message.STATUS_SEND_FAILED
@@ -2083,6 +2182,14 @@ public class ConversationFragment extends XmppFragment
                     && m.getStatus() != Message.STATUS_SEND_FAILED
                     && m.getConversation().getAccount().isOnlineAndConnected();
             retractMessage.setVisible(canRetract);
+            // Pin/unpin: any non-deleted, non-error message with a stable UUID.
+            if (!deleted && !showError && conversational instanceof Conversation c) {
+                if (c.isPinned(m.getUuid())) {
+                    unpinMessage.setVisible(true);
+                } else {
+                    pinMessage.setVisible(true);
+                }
+            }
             // Quote/reply: works for text messages AND file messages with a remote URL.
             if (!showError && !deleted && !encrypted) {
                 if (m.isFileOrImage()) {
@@ -2217,7 +2324,7 @@ public class ConversationFragment extends XmppFragment
                 }
             }
             final String mime = m.isFileOrImage() ? m.getMimeType() : null;
-            if ((m.isGeoUri() && GeoHelper.openInOsmAnd(getActivity(), m))
+            if ((m.isGeoUri() && GeoHelper.isResolveUniversalUri(getActivity(), m))
                     || (mime != null && mime.startsWith("audio/"))) {
                 openWith.setVisible(true);
             }
@@ -2323,6 +2430,14 @@ public class ConversationFragment extends XmppFragment
                 requireXmppActivity().xmppConnectionService.retractMessage(selectedMessage);
                 yield true;
             }
+            case R.id.action_pin_message -> {
+                togglePinMessage(selectedMessage, true);
+                yield true;
+            }
+            case R.id.action_unpin_message -> {
+                togglePinMessage(selectedMessage, false);
+                yield true;
+            }
             case R.id.action_select_message -> {
                 enterSelectionMode(selectedMessage);
                 yield true;
@@ -2467,6 +2582,90 @@ public class ConversationFragment extends XmppFragment
         this.binding.toolbar.invalidateMenu();
     }
 
+    private void refreshPinnedMessagesBanner() {
+        if (this.binding == null || this.conversation == null) {
+            return;
+        }
+        this.binding.toolbar.invalidateMenu();
+        final List<String> pinned = conversation.getPinnedMessageUuids();
+        if (pinned.isEmpty()) {
+            this.binding.pinnedMessagesBanner.setVisibility(View.GONE);
+            return;
+        }
+        if (currentPinnedIndex >= pinned.size()) {
+            currentPinnedIndex = 0;
+        }
+        final String uuid = pinned.get(currentPinnedIndex);
+        final Message message = conversation.findMessageWithUuid(uuid);
+        this.binding.pinnedMessagesBanner.setVisibility(View.VISIBLE);
+        this.binding.pinnedMessagesTitle.setText(
+                getString(R.string.pinned_message)
+                        + (pinned.size() > 1
+                                ? "  "
+                                        + getString(
+                                                R.string.pinned_messages_counter,
+                                                currentPinnedIndex + 1,
+                                                pinned.size())
+                                : ""));
+        final int cycleVisibility = pinned.size() > 1 ? View.VISIBLE : View.GONE;
+        this.binding.pinnedMessagesCycle.setVisibility(cycleVisibility);
+        this.binding.pinnedMessagesCyclePrev.setVisibility(cycleVisibility);
+        if (message != null) {
+            final var preview = UIHelper.getMessagePreview(requireContext(), message);
+            this.binding.pinnedMessagesSnippet.setText(
+                    UIHelper.getMessageDisplayName(message) + ": " + preview.first);
+        } else {
+            this.binding.pinnedMessagesSnippet.setText("");
+        }
+    }
+
+    private void onPinnedMessagesBannerTapped() {
+        final List<String> pinned = conversation.getPinnedMessageUuids();
+        if (pinned.isEmpty() || currentPinnedIndex >= pinned.size()) {
+            return;
+        }
+        scrollToMessage(pinned.get(currentPinnedIndex));
+    }
+
+    private void cyclePinnedMessagesBanner(final int direction) {
+        final List<String> pinned = conversation.getPinnedMessageUuids();
+        if (pinned.isEmpty()) {
+            return;
+        }
+        currentPinnedIndex =
+                Math.floorMod(currentPinnedIndex + direction, pinned.size());
+        refreshPinnedMessagesBanner();
+    }
+
+    private void unpinCurrentBannerMessage() {
+        final List<String> pinned = conversation.getPinnedMessageUuids();
+        if (pinned.isEmpty() || currentPinnedIndex >= pinned.size()) {
+            return;
+        }
+        conversation.unpinMessage(pinned.get(currentPinnedIndex));
+        requireXmppActivity().xmppConnectionService.updateConversation(conversation);
+        refreshPinnedMessagesBanner();
+        this.messageListAdapter.notifyDataSetChanged();
+    }
+
+    private void openPinnedMessagesList() {
+        final Intent intent = new Intent(requireContext(), PinnedMessagesActivity.class);
+        intent.putExtra(PinnedMessagesActivity.EXTRA_CONVERSATION_UUID, conversation.getUuid());
+        startActivity(intent);
+    }
+
+    private void togglePinMessage(final Message message, final boolean pin) {
+        if (pin) {
+            conversation.pinMessage(message.getUuid());
+        } else {
+            conversation.unpinMessage(message.getUuid());
+        }
+        requireXmppActivity().xmppConnectionService.updateConversation(conversation);
+        currentPinnedIndex = 0;
+        refreshPinnedMessagesBanner();
+        this.messageListAdapter.notifyDataSetChanged();
+    }
+
     private void showEphemeralTimerDialog(final Conversation conv) {
         // timer options: 0=off, 30s, 1m, 5m, 15m, 30m, 1h, 3h, 1d, 1w
         final long[] timerValues = {0, 30, 60, 300, 900, 1800, 3600, 10800, 86400, 604800};
@@ -3869,6 +4068,10 @@ public class ConversationFragment extends XmppFragment
             return false;
         }
 
+        updateBotCommandTabVisibility();
+        currentPinnedIndex = 0;
+        refreshPinnedMessagesBanner();
+
         stopScrolling();
         Log.d(Config.LOGTAG, "reInit(hasExtras=" + hasExtras + ")");
 
@@ -4108,16 +4311,16 @@ public class ConversationFragment extends XmppFragment
             startDownloadable(message);
         }
         if (scrollToUuid != null) {
-            final String uuid = scrollToUuid;
-            this.binding.messagesView.post(
-                    () -> {
-                        int pos = getIndexOf(uuid, this.messageList);
-                        if (pos >= 0) {
-                            setSelection(pos, false);
-                        } else {
-                            scrollToMessageFromDb(uuid);
-                        }
-                    });
+            this.binding.messagesView.post(() -> scrollToMessage(scrollToUuid));
+        }
+    }
+
+    private void scrollToMessage(final String uuid) {
+        final int pos = getIndexOf(uuid, this.messageList);
+        if (pos >= 0) {
+            setSelection(pos, false);
+        } else {
+            scrollToMessageFromDb(uuid);
         }
     }
 
@@ -4696,8 +4899,19 @@ public class ConversationFragment extends XmppFragment
                                             R.string.contact_uses_jabjab_toast,
                                             Toast.LENGTH_SHORT)
                                     .show());
+            final boolean isVerified =
+                    Config.VERIFIED_JIDS.contains(contact.getAddress().asBareJid());
+            this.binding.verifiedBadgeIcon.setVisibility(isVerified ? View.VISIBLE : View.GONE);
+            this.binding.verifiedBadgeIcon.setOnClickListener(
+                    v ->
+                            Toast.makeText(
+                                            requireContext(),
+                                            R.string.contact_is_verified_toast,
+                                            Toast.LENGTH_SHORT)
+                                    .show());
         } else if (c.getMode() == Conversation.MODE_MULTI) {
             this.binding.jabjabBadgeIcon.setVisibility(View.GONE);
+            this.binding.verifiedBadgeIcon.setVisibility(View.GONE);
             this.binding.toolbarStatusText.setVisibility(View.GONE);
             final var mucOptions = conversation.getMucOptions();
             final var userCount = mucOptions.getUserCount();
@@ -4710,6 +4924,7 @@ public class ConversationFragment extends XmppFragment
             }
         } else {
             this.binding.jabjabBadgeIcon.setVisibility(View.GONE);
+            this.binding.verifiedBadgeIcon.setVisibility(View.GONE);
             this.binding.toolbarStatusText.setVisibility(View.GONE);
             this.binding.toolbar.setSubtitle(null);
         }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
index 89f7b22..c4a9c14 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
@@ -515,7 +515,7 @@ public class EditAccountActivity extends OmemoActivity
             }
             final Intent intent =
                     SignupUtils.getTokenRegistrationIntent(
-                            this, preset, mAccount.getKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN));
+                            this, preset, mAccount.getPreAuthRegistrationToken().orNull());
             StartConversationActivity.addInviteUri(intent, this);
             startActivity(intent);
             return;
@@ -1188,7 +1188,7 @@ public class EditAccountActivity extends OmemoActivity
                             new PresenceTemplate(
                                     getAvailabilityRadioButton(binding),
                                     binding.statusMessage.getText().toString().trim());
-                    if (mAccount.getPgpId() != 0 && hasPgp()) {
+                    if (mAccount.getPgpId().isPresent() && hasPgp()) {
                         generateSignature(null, template);
                     } else {
                         xmppConnectionService.changeStatus(mAccount, template, null);
@@ -1416,12 +1416,12 @@ public class EditAccountActivity extends OmemoActivity
                 this.binding.pushRow.setVisibility(View.VISIBLE);
             }
 
-            final long pgpKeyId = this.mAccount.getPgpId();
-            if (pgpKeyId != 0) {
-                OnClickListener openPgp = view -> launchOpenKeyChain(pgpKeyId);
+            final var pgpKeyId = this.mAccount.getPgpId();
+            if (pgpKeyId.isPresent()) {
+                OnClickListener openPgp = view -> launchOpenKeyChain(pgpKeyId.get());
                 OnClickListener delete = view -> showDeletePgpDialog();
                 this.binding.pgpFingerprintBox.setVisibility(View.VISIBLE);
-                this.binding.pgpFingerprint.setText(OpenPgpUtils.convertKeyIdToHex(pgpKeyId));
+                this.binding.pgpFingerprint.setText(OpenPgpUtils.convertKeyIdToHex(pgpKeyId.get()));
                 this.binding.pgpFingerprint.setOnClickListener(openPgp);
                 if ("pgp".equals(messageFingerprint)) {
                     this.binding.pgpFingerprintDesc.setTextColor(
@@ -1611,8 +1611,7 @@ public class EditAccountActivity extends OmemoActivity
         builder.setPositiveButton(
                 R.string.confirm,
                 (dialogInterface, i) -> {
-                    mAccount.setPgpSignId(0);
-                    mAccount.unsetPgpSignature();
+                    mAccount.resetPgp();
                     xmppConnectionService.databaseBackend.updateAccount(mAccount);
                     mAccount.getXmppConnection().getManager(PresenceManager.class).available();
                     refreshUiReal();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/PinnedMessagesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/PinnedMessagesActivity.java
new file mode 100644
index 0000000..750a163
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/PinnedMessagesActivity.java
@@ -0,0 +1,161 @@
+package tel.xmpp.jabjab.ui;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.ContextMenu;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.AdapterView;
+import androidx.databinding.DataBindingUtil;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.databinding.ActivityPinnedMessagesBinding;
+import tel.xmpp.jabjab.entities.Contact;
+import tel.xmpp.jabjab.entities.Conversation;
+import tel.xmpp.jabjab.entities.Message;
+import tel.xmpp.jabjab.ui.adapter.MessageAdapter;
+
+public class PinnedMessagesActivity extends XmppActivity
+        implements MessageAdapter.OnContactPictureClicked {
+
+    public static final String EXTRA_CONVERSATION_UUID = "uuid";
+
+    private ActivityPinnedMessagesBinding binding;
+    private MessageAdapter messageListAdapter;
+    private final List<Message> messages = new ArrayList<>();
+    private WeakReference<Message> selectedMessageReference = new WeakReference<>(null);
+    private String conversationUuid;
+
+    @Override
+    protected void onCreate(final Bundle bundle) {
+        super.onCreate(bundle);
+        final Intent intent = getIntent();
+        this.conversationUuid =
+                intent == null ? null : intent.getStringExtra(EXTRA_CONVERSATION_UUID);
+        this.binding = DataBindingUtil.setContentView(this, R.layout.activity_pinned_messages);
+        Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
+        setSupportActionBar(this.binding.toolbar);
+        configureActionBar(getSupportActionBar());
+        setTitle(R.string.pinned_messages);
+        this.messageListAdapter = new MessageAdapter(this, this.messages, true);
+        this.messageListAdapter.setOnContactPictureClicked(this);
+        this.binding.pinnedMessagesList.setAdapter(messageListAdapter);
+        registerForContextMenu(this.binding.pinnedMessagesList);
+        this.binding.pinnedMessagesList.setOnItemClickListener(
+                (parent, view, position, id) -> {
+                    final Message message = messages.get(position);
+                    final Intent openIntent = new Intent(this, ConversationsActivity.class);
+                    openIntent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION);
+                    openIntent.putExtra(
+                            ConversationsActivity.EXTRA_CONVERSATION, message.getConversationUuid());
+                    openIntent.putExtra(
+                            ConversationsActivity.EXTRA_SCROLL_TO_MESSAGE, message.getUuid());
+                    startActivity(openIntent);
+                    finish();
+                });
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        loadPinnedMessages();
+    }
+
+    @Override
+    protected void onBackendConnected() {
+        loadPinnedMessages();
+    }
+
+    private void loadPinnedMessages() {
+        if (xmppConnectionService == null || conversationUuid == null) {
+            return;
+        }
+        final Conversation conversation =
+                xmppConnectionService.findConversationByUuid(conversationUuid);
+        if (conversation == null) {
+            return;
+        }
+        final List<String> uuids = conversation.getPinnedMessageUuids();
+        final List<Message> resolved = new ArrayList<>();
+        for (final String uuid : uuids) {
+            final Message message =
+                    xmppConnectionService.databaseBackend.getMessageWithUuidOrRemoteId(
+                            conversation, uuid);
+            if (message != null) {
+                resolved.add(message);
+            }
+        }
+        this.messages.clear();
+        this.messages.addAll(resolved);
+        this.messageListAdapter.notifyDataSetChanged();
+        this.binding.emptyState.setVisibility(resolved.isEmpty() ? View.VISIBLE : View.GONE);
+        this.binding.pinnedMessagesList.setVisibility(resolved.isEmpty() ? View.GONE : View.VISIBLE);
+    }
+
+    @Override
+    public void onCreateContextMenu(
+            final ContextMenu menu, final View v, final ContextMenu.ContextMenuInfo menuInfo) {
+        v.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
+        final AdapterView.AdapterContextMenuInfo acmi =
+                (AdapterView.AdapterContextMenuInfo) menuInfo;
+        final Message message = this.messages.get(acmi.position);
+        this.selectedMessageReference = new WeakReference<>(message);
+        getMenuInflater().inflate(R.menu.pinned_message_context, menu);
+        super.onCreateContextMenu(menu, v, menuInfo);
+    }
+
+    @Override
+    public boolean onContextItemSelected(final MenuItem item) {
+        final Message message = selectedMessageReference.get();
+        if (message != null && item.getItemId() == R.id.action_unpin_message) {
+            final Conversation conversation =
+                    xmppConnectionService.findConversationByUuid(conversationUuid);
+            if (conversation != null) {
+                conversation.unpinMessage(message.getUuid());
+                xmppConnectionService.updateConversation(conversation);
+                loadPinnedMessages();
+            }
+            return true;
+        }
+        return super.onContextItemSelected(item);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    protected void refreshUiReal() {}
+
+    @Override
+    public void onContactPictureClicked(final Message message) {
+        String fingerprint;
+        if (message.getEncryption() == Message.ENCRYPTION_PGP
+                || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
+            fingerprint = "pgp";
+        } else {
+            fingerprint = message.getFingerprint();
+        }
+        if (message.getStatus() == Message.STATUS_RECEIVED) {
+            final Contact contact = message.getContact();
+            if (contact != null) {
+                if (contact.isSelf()) {
+                    switchToAccount(message.getConversation().getAccount(), fingerprint);
+                } else {
+                    switchToContactDetails(contact, fingerprint);
+                }
+            }
+        } else {
+            switchToAccount(message.getConversation().getAccount(), fingerprint);
+        }
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ShowLocationActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ShowLocationActivity.java
index 7bd99c6..d92e730 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ShowLocationActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ShowLocationActivity.java
@@ -17,17 +17,14 @@ import android.widget.Toast;
 import androidx.annotation.NonNull;
 import androidx.databinding.DataBindingUtil;
 import com.google.common.base.Strings;
-import com.google.common.primitives.Doubles;
+import de.gultsch.common.MiniUri;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.databinding.ActivityShowLocationBinding;
 import tel.xmpp.jabjab.ui.util.LocationHelper;
-import tel.xmpp.jabjab.ui.util.UriHelper;
 import tel.xmpp.jabjab.ui.widget.Marker;
 import tel.xmpp.jabjab.ui.widget.MyLocation;
-import tel.xmpp.jabjab.utils.GeoHelper;
 import tel.xmpp.jabjab.utils.LocationProvider;
-import java.util.Map;
 import org.osmdroid.util.GeoPoint;
 
 public class ShowLocationActivity extends LocationActivity implements LocationListener {
@@ -69,24 +66,17 @@ public class ShowLocationActivity extends LocationActivity implements LocationLi
                 }
                 break;
             case Intent.ACTION_VIEW:
-                final Uri uri = intent.getData();
-                if (uri == null) {
-                    break;
-                }
-                final GeoPoint point;
-                try {
-                    point = GeoHelper.parseGeoPoint(uri);
-                } catch (final Exception e) {
-                    break;
-                }
-                this.loc = point;
-                final Map<String, String> query = UriHelper.parseQueryString(uri.getQuery());
-                final String z = query.get("z");
-                final Double zoom = Strings.isNullOrEmpty(z) ? null : Doubles.tryParse(z);
-                if (zoom != null) {
-                    Log.d(Config.LOGTAG, "inferring zoom level " + zoom + " from geo uri");
-                    mapController.setZoom(zoom);
-                    gotoLoc(false);
+                final var uri = MiniUri.getOrNull(intent.getData());
+                if (uri instanceof MiniUri.Geo geo) {
+                    this.loc = geo.asGeoPoint();
+                    final var zoom = geo.getZoom();
+                    if (zoom.isPresent()) {
+                        Log.d(
+                                Config.LOGTAG,
+                                "inferring zoom level " + zoom.get() + " from geo uri");
+                        mapController.setZoom((double) zoom.get());
+                        gotoLoc(false);
+                    }
                 }
                 break;
         }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
index 2d6fcbc..f88712e 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
@@ -797,15 +797,14 @@ public abstract class XmppActivity extends ActionBarActivity {
             final Conversation conversation,
             Intent intent,
             final Runnable onSuccess) {
-        if (account.getPgpId() == 0) {
-            choosePgpSignId(account);
-        } else {
+        final var pgpId = account.getPgpId();
+        if (pgpId.isPresent()) {
             final String status = Strings.nullToEmpty(account.getPresenceStatusMessage());
             final var future =
                     xmppConnectionService.getPgpEngine().generateSignature(intent, account, status);
             Futures.addCallback(
                     future,
-                    new FutureCallback<String>() {
+                    new FutureCallback<>() {
                         @Override
                         public void onSuccess(String signature) {
                             account.setPgpSignature(signature);
@@ -839,14 +838,15 @@ public abstract class XmppActivity extends ActionBarActivity {
                                 } catch (final SendIntentException ignored) {
                                 }
                             } else {
-                                account.setPgpSignId(0);
-                                account.unsetPgpSignature();
+                                account.resetPgp();
                                 xmppConnectionService.databaseBackend.updateAccount(account);
                                 choosePgpSignId(account);
                             }
                         }
                     },
                     ContextCompat.getMainExecutor(this));
+        } else {
+            choosePgpSignId(account);
         }
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/BotCommandAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/BotCommandAdapter.java
new file mode 100644
index 0000000..fc1ca89
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/BotCommandAdapter.java
@@ -0,0 +1,66 @@
+package tel.xmpp.jabjab.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import com.google.common.base.Strings;
+import im.conversations.android.xmpp.model.disco.items.Item;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import tel.xmpp.jabjab.R;
+
+public class BotCommandAdapter extends RecyclerView.Adapter<BotCommandAdapter.ViewHolder> {
+
+    private final List<Item> items = new ArrayList<>();
+    private final Consumer<Item> onCommandClicked;
+
+    public BotCommandAdapter(final Consumer<Item> onCommandClicked) {
+        this.onCommandClicked = onCommandClicked;
+    }
+
+    public void submitList(final List<Item> items) {
+        this.items.clear();
+        this.items.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+        final var view =
+                LayoutInflater.from(parent.getContext())
+                        .inflate(R.layout.item_bot_command, parent, false);
+        return new ViewHolder(view);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) {
+        final var item = this.items.get(position);
+        final var node = item.getNode();
+        final var name = item.getItemName();
+        holder.name.setText(Strings.isNullOrEmpty(name) ? node : name);
+        holder.node.setText(node);
+        holder.node.setVisibility(Strings.isNullOrEmpty(name) ? View.GONE : View.VISIBLE);
+        holder.itemView.setOnClickListener(v -> onCommandClicked.accept(item));
+    }
+
+    @Override
+    public int getItemCount() {
+        return this.items.size();
+    }
+
+    static class ViewHolder extends RecyclerView.ViewHolder {
+        final TextView name;
+        final TextView node;
+
+        ViewHolder(@NonNull final View itemView) {
+            super(itemView);
+            this.name = itemView.findViewById(R.id.command_name);
+            this.node = itemView.findViewById(R.id.command_node);
+        }
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
index 820f5ca..325a0fa 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
@@ -309,6 +309,18 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             viewHolder.indicatorEdit().setVisibility(View.GONE);
         }
 
+        if (message.getConversation() instanceof Conversation conv
+                && conv.isPinned(message.getUuid())) {
+            viewHolder.indicatorPinned().setVisibility(View.VISIBLE);
+            if (error && sent) {
+                setImageTintError(viewHolder.indicatorPinned());
+            } else {
+                setImageTint(viewHolder.indicatorPinned(), bubbleColor);
+            }
+        } else {
+            viewHolder.indicatorPinned().setVisibility(View.GONE);
+        }
+
         final String formattedTime =
                 UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
         final String bodyLanguage = message.getBodyLanguage();
@@ -2793,6 +2805,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
         protected abstract ImageView indicatorEdit();
 
+        protected abstract ImageView indicatorPinned();
+
         protected abstract RelativeLayout audioPlayer();
 
         protected abstract LinearLayout messageBox();
@@ -2861,6 +2875,11 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             return this.binding.editIndicator;
         }
 
+        @Override
+        protected ImageView indicatorPinned() {
+            return this.binding.pinnedIndicator;
+        }
+
         @Override
         protected RelativeLayout audioPlayer() {
             return this.binding.messageContent.audioPlayer;
@@ -3009,6 +3028,11 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             return this.binding.editIndicator;
         }
 
+        @Override
+        protected ImageView indicatorPinned() {
+            return this.binding.pinnedIndicator;
+        }
+
         @Override
         protected RelativeLayout audioPlayer() {
             return this.binding.messageContent.audioPlayer;
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
index d69776f..e29efde 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
@@ -117,7 +117,7 @@ public class UpdateChecker {
         final Request request = new Request.Builder()
                 .url(UPDATE_JSON_URL)
                 .header("Cache-Control", "no-cache")
-                .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME)
+                .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME + "+" + BuildConfig.VERSION_CODE)
                 .build();
         final Response response;
         try {
diff --git a/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java b/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
index 5920b83..89f405f 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
@@ -3,12 +3,11 @@ package tel.xmpp.jabjab.utils;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
+import de.gultsch.common.MiniUri;
 import de.gultsch.common.Patterns;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.entities.Message;
 import tel.xmpp.jabjab.ui.ShowLocationActivity;
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
 import java.util.regex.Matcher;
 import org.osmdroid.util.GeoPoint;
 
@@ -40,55 +39,41 @@ public class GeoHelper {
         return new GeoPoint(latitude, longitude);
     }
 
-    public static void view(Context context, Message message) {
-        final GeoPoint geoPoint = parseGeoPoint(message.getBody());
-        final String label = getLabel(context, message);
-        context.startActivity(geoIntent(geoPoint, label));
-    }
-
-    private static Intent geoIntent(GeoPoint geoPoint, String label) {
-        // TODO use mini uri geo
-        Intent geoIntent = new Intent(Intent.ACTION_VIEW);
-        geoIntent.setData(
-                Uri.parse(
-                        "geo:"
-                                + geoPoint.getLatitude()
-                                + ","
-                                + geoPoint.getLongitude()
-                                + "?q="
-                                + geoPoint.getLatitude()
-                                + ","
-                                + geoPoint.getLongitude()
-                                + "("
-                                + label
-                                + ")"));
-        return geoIntent;
-    }
-
-    public static boolean openInOsmAnd(Context context, Message message) {
-        try {
-            final GeoPoint geoPoint = parseGeoPoint(message.getBody());
-            final String label = getLabel(context, message);
-            return geoIntent(geoPoint, label).resolveActivity(context.getPackageManager()) != null;
-        } catch (IllegalArgumentException e) {
-            return false;
+    public static void view(final Context context, final Message message) {
+        final var miniUri = MiniUri.getOrNull(message.getBody());
+        if (miniUri instanceof MiniUri.Geo geo) {
+            final var intent = new Intent(Intent.ACTION_VIEW);
+            final String label;
+            if (message.getStatus() == Message.STATUS_RECEIVED) {
+                label = UIHelper.getMessageDisplayName(message);
+            } else {
+                label = context.getString(R.string.me);
+            }
+            intent.setData(geo.asUniversalUri(label));
+            context.startActivity(intent);
         }
     }
 
-    private static String getLabel(Context context, Message message) {
-        if (message.getStatus() == Message.STATUS_RECEIVED) {
-            try {
-                return URLEncoder.encode(UIHelper.getMessageDisplayName(message), "UTF-8");
-            } catch (final UnsupportedEncodingException e) {
-                throw new AssertionError(e);
-            }
+    public static boolean isResolveUniversalUri(final Context context, final Message message) {
+        final var miniUri = MiniUri.getOrNull(message.getBody());
+        if (miniUri instanceof MiniUri.Geo geo) {
+            final var intent = new Intent(Intent.ACTION_VIEW);
+            return intent.setData(geo.asUniversalUri()).resolveActivity(context.getPackageManager())
+                    != null;
         } else {
-            return context.getString(R.string.me);
+            return false;
         }
     }
 
     public static Intent showLocationIntent(final Context context, final Message message) {
-        return showLocationIntent(context, GeoHelper.parseGeoPoint(message.getBody()));
+        final GeoPoint geoPoint;
+        final var miniUri = MiniUri.getOrNull(message.getBody());
+        if (miniUri instanceof MiniUri.Geo geo) {
+            geoPoint = geo.asGeoPoint();
+        } else {
+            geoPoint = new GeoPoint(0.0, 0.0);
+        }
+        return showLocationIntent(context, geoPoint);
     }
 
     public static Intent showLocationIntent(final Context context, final GeoPoint geoPoint) {
diff --git a/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java b/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
index c5b0718..5cfae77 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
@@ -40,6 +40,7 @@ import tel.xmpp.jabjab.services.XmppConnectionService;
 import tel.xmpp.jabjab.utils.AccountUtils;
 import tel.xmpp.jabjab.utils.BackupFileHeader;
 import tel.xmpp.jabjab.xmpp.Jid;
+import im.conversations.android.json.Services;
 import java.io.BufferedReader;
 import java.io.DataInputStream;
 import java.io.File;
@@ -63,8 +64,6 @@ import org.bouncycastle.crypto.modes.AEADBlockCipher;
 import org.bouncycastle.crypto.modes.GCMBlockCipher;
 import org.bouncycastle.crypto.params.AEADParameters;
 import org.bouncycastle.crypto.params.KeyParameter;
-import org.json.JSONException;
-import org.json.JSONObject;
 
 public class ImportBackupWorker extends Worker {
 
@@ -304,17 +303,15 @@ public class ImportBackupWorker extends Worker {
             } else {
                 throw new IOException("jid or password in table did not match backup");
             }
-            final var keys = Account.parseKeys(contentValues.getAsString(Account.KEYS));
-            final var deviceId = keys.optString(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID);
-            final var importReadyKeys = new JSONObject();
-            if (!Strings.isNullOrEmpty(deviceId) && this.includeOmemo) {
-                try {
-                    importReadyKeys.put(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID, deviceId);
-                } catch (final JSONException e) {
-                    Log.e(Config.LOGTAG, "error writing omemo registration id", e);
-                }
+            final var keys = Account.Keys.parse(contentValues.getAsString(Account.KEYS));
+            final var deviceId = keys.getAxolotlRegistrationId();
+            final Account.Keys importReadyKeys;
+            if (deviceId.isPresent() && this.includeOmemo) {
+                importReadyKeys = new Account.Keys(deviceId.get());
+            } else {
+                importReadyKeys = new Account.Keys();
             }
-            contentValues.put(Account.KEYS, importReadyKeys.toString());
+            contentValues.put(Account.KEYS, Services.GSON.toJson(importReadyKeys));
         }
         if (this.includeOmemo) {
             db.insert(table, null, contentValues);
diff --git a/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
index a591b27..eb4c216 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
@@ -183,7 +183,9 @@ public class UpdateDownloadWorker extends Worker {
         final Request.Builder requestBuilder =
                 new Request.Builder()
                         .url(downloadUrl)
-                        .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME);
+                        .header(
+                                "User-Agent",
+                                "JabJab/" + BuildConfig.VERSION_NAME + "+" + BuildConfig.VERSION_CODE);
         if (existingBytes > 0) {
             requestBuilder.header("Range", "bytes=" + existingBytes + "-");
             Log.d(Config.LOGTAG, "UPDATE resuming download from byte " + existingBytes);
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
index 4e53b8c..bd3bd40 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
@@ -1970,12 +1970,11 @@ public class XmppConnection implements Runnable {
     }
 
     private void register() {
-        final String preAuthToken =
-                Strings.emptyToNull(account.getKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN));
+        final var preAuthToken = account.getPreAuthRegistrationToken();
         final ListenableFuture<RegistrationManager.Registration> registrationFuture;
-        if (preAuthToken != null && streamFeatures.preAuthenticatedInBandRegistration()) {
+        if (preAuthToken.isPresent() && streamFeatures.preAuthenticatedInBandRegistration()) {
             registrationFuture =
-                    getManager(RegistrationManager.class).getRegistration(preAuthToken);
+                    getManager(RegistrationManager.class).getRegistration(preAuthToken.get());
         } else {
             registrationFuture = getManager(RegistrationManager.class).getRegistration();
         }
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
index 3f885b8..9449a71 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DiscoManager.java
@@ -272,8 +272,19 @@ public class DiscoManager extends AbstractManager {
                         throw new IllegalStateException();
                     }
                     if (!Objects.equals(requestNode, itemsQuery.getNode())) {
-                        throw new IllegalStateException(
-                                "Node in response did not match node in request");
+                        // XEP-0030 says the responder SHOULD echo the requested node back, not
+                        // MUST — some real-world responders (e.g. ad-hoc command listings on
+                        // third-party components) omit it or return it inconsistently. Treat
+                        // this as a soft mismatch and use the results anyway rather than
+                        // discarding a perfectly valid response over it.
+                        Log.d(
+                                Config.LOGTAG,
+                                "disco#items node in response ("
+                                        + itemsQuery.getNode()
+                                        + ") did not match request ("
+                                        + requestNode
+                                        + ") for "
+                                        + entity.address);
                     }
                     final var items = itemsQuery.getExtensions(Item.class);
 
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java
index d4dfbda..ffbc372 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java
@@ -78,15 +78,14 @@ public class JingleMessageManager extends AbstractManager {
                     final Conversation c =
                             this.service.findOrCreateConversation(
                                     getAccount(), counterpart.asBareJid(), false, false);
-                    final tel.xmpp.jabjab.entities.Message preExistingMessage =
-                            c.findRtpSession(sessionId, status);
+                    final var preExistingMessage = c.findRtpSession(sessionId, status);
                     if (preExistingMessage != null) {
                         preExistingMessage.setServerMsgId(serverMsgId);
                         getDatabase().updateMessage(preExistingMessage, true);
                         this.service.updateConversationUi();
                         return;
                     }
-                    final tel.xmpp.jabjab.entities.Message message =
+                    final var message =
                             new tel.xmpp.jabjab.entities.Message(
                                     c,
                                     status,
diff --git a/src/main/res/drawable/ic_expand_less_24dp.xml b/src/main/res/drawable/ic_expand_less_24dp.xml
new file mode 100644
index 0000000..5883311
--- /dev/null
+++ b/src/main/res/drawable/ic_expand_less_24dp.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z" />
+</vector>
diff --git a/src/main/res/drawable/ic_expand_more_24dp.xml b/src/main/res/drawable/ic_expand_more_24dp.xml
new file mode 100644
index 0000000..7ade5b8
--- /dev/null
+++ b/src/main/res/drawable/ic_expand_more_24dp.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
+</vector>
diff --git a/src/main/res/drawable/ic_push_pin_24dp.xml b/src/main/res/drawable/ic_push_pin_24dp.xml
new file mode 100644
index 0000000..3b82344
--- /dev/null
+++ b/src/main/res/drawable/ic_push_pin_24dp.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
+</vector>
diff --git a/src/main/res/layout/activity_contact_details.xml b/src/main/res/layout/activity_contact_details.xml
index 7e0f4b7..ac7a4fa 100644
--- a/src/main/res/layout/activity_contact_details.xml
+++ b/src/main/res/layout/activity_contact_details.xml
@@ -53,17 +53,42 @@
                             android:scaleType="centerCrop"
                             app:shapeAppearanceOverlay="@style/ShapeAppearance.RoundedCornersMedium" />
 
-                        <TextView
-                            android:id="@+id/details_contact_xmpp_address"
+                        <LinearLayout
+                            android:id="@+id/details_contact_name_box"
                             android:layout_width="wrap_content"
                             android:layout_height="wrap_content"
                             android:layout_below="@+id/details_avatar"
                             android:layout_centerHorizontal="true"
                             android:layout_marginTop="16sp"
-                            android:gravity="center_horizontal"
+                            android:gravity="center"
                             android:minWidth="288dp"
-                            android:text="@string/account_settings_example_jabber_id"
-                            android:textAppearance="?textAppearanceTitleLarge" />
+                            android:orientation="horizontal">
+
+                            <TextView
+                                android:id="@+id/details_contact_xmpp_address"
+                                android:layout_width="wrap_content"
+                                android:layout_height="wrap_content"
+                                android:layout_gravity="center_vertical"
+                                android:gravity="center_horizontal"
+                                android:text="@string/account_settings_example_jabber_id"
+                                android:textAppearance="?textAppearanceTitleLarge" />
+
+                            <ImageView
+                                android:id="@+id/details_verified_badge_icon"
+                                android:layout_width="18dp"
+                                android:layout_height="18dp"
+                                android:layout_gravity="center_vertical"
+                                android:layout_marginStart="6dp"
+                                android:background="?attr/selectableItemBackgroundBorderless"
+                                android:clickable="true"
+                                android:contentDescription="@string/contact_is_verified"
+                                android:focusable="true"
+                                android:padding="1dp"
+                                android:src="@drawable/ic_check_circle_24dp"
+                                android:visibility="gone"
+                                app:tint="?attr/colorPrimary" />
+
+                        </LinearLayout>
 
 
 
@@ -71,7 +96,7 @@
                             android:id="@+id/button_box"
                             android:layout_width="wrap_content"
                             android:layout_height="wrap_content"
-                            android:layout_below="@+id/details_contact_xmpp_address"
+                            android:layout_below="@+id/details_contact_name_box"
                             android:layout_centerHorizontal="true"
                             android:layout_marginTop="16dp"
                             android:orientation="horizontal">
@@ -122,7 +147,7 @@
                             android:layout_width="wrap_content"
                             android:layout_height="wrap_content"
                             android:layout_below="@+id/tags"
-                            android:layout_alignStart="@+id/details_contact_xmpp_address"
+                            android:layout_alignStart="@+id/details_contact_name_box"
                             android:layout_marginTop="8sp"
                             android:orientation="vertical">
 
diff --git a/src/main/res/layout/activity_pinned_messages.xml b/src/main/res/layout/activity_pinned_messages.xml
new file mode 100644
index 0000000..c366a52
--- /dev/null
+++ b/src/main/res/layout/activity_pinned_messages.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<layout xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
+        android:orientation="vertical">
+
+        <com.google.android.material.appbar.AppBarLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <com.google.android.material.appbar.MaterialToolbar
+                android:id="@+id/toolbar"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:minHeight="?attr/actionBarSize" />
+
+        </com.google.android.material.appbar.AppBarLayout>
+
+        <TextView
+            android:id="@+id/empty_state"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:gravity="center"
+            android:padding="32dp"
+            android:text="@string/no_pinned_messages"
+            android:textAppearance="?textAppearanceBodyLarge"
+            android:textColor="?colorOnSurfaceVariant"
+            android:visibility="gone" />
+
+        <ListView
+            android:id="@+id/pinned_messages_list"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:divider="@android:color/transparent"
+            android:dividerHeight="0dp"
+            android:listSelector="@android:color/transparent" />
+    </LinearLayout>
+</layout>
diff --git a/src/main/res/layout/dialog_command_form.xml b/src/main/res/layout/dialog_command_form.xml
new file mode 100644
index 0000000..044d23d
--- /dev/null
+++ b/src/main/res/layout/dialog_command_form.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:paddingHorizontal="24dp"
+    android:paddingTop="8dp"
+    android:paddingBottom="16dp">
+
+    <LinearLayout
+        android:id="@+id/command_form_fields"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical" />
+
+</ScrollView>
diff --git a/src/main/res/layout/fragment_bot_commands.xml b/src/main/res/layout/fragment_bot_commands.xml
new file mode 100644
index 0000000..7b1e336
--- /dev/null
+++ b/src/main/res/layout/fragment_bot_commands.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/commands_list"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:clipToPadding="false"
+        android:paddingVertical="8dp" />
+
+    <ProgressBar
+        android:id="@+id/commands_loading"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:visibility="gone" />
+
+    <TextView
+        android:id="@+id/commands_empty"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:padding="24dp"
+        android:textAppearance="?attr/textAppearanceBodyMedium"
+        android:textColor="?colorOnSurfaceVariant"
+        android:visibility="gone" />
+
+</FrameLayout>
diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml
index db0fa3c..9b03e3f 100644
--- a/src/main/res/layout/fragment_conversation.xml
+++ b/src/main/res/layout/fragment_conversation.xml
@@ -1,6 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <layout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
 
     <LinearLayout
         android:layout_width="match_parent"
@@ -42,22 +43,25 @@
                         android:maxLines="1"
                         android:textAppearance="?attr/textAppearanceTitleLarge" />
 
-                    <TextView
-                        android:id="@+id/toolbar_status_text"
-                        android:layout_width="wrap_content"
-                        android:layout_height="wrap_content"
-                        android:layout_marginStart="6dp"
-                        android:ellipsize="end"
-                        android:maxLines="1"
-                        android:textAppearance="?attr/textAppearanceBodySmall"
-                        android:textColor="?colorOnSurfaceVariant"
-                        android:visibility="gone" />
+                    <ImageView
+                        android:id="@+id/verified_badge_icon"
+                        android:layout_width="16dp"
+                        android:layout_height="16dp"
+                        android:layout_marginStart="4dp"
+                        android:background="?attr/selectableItemBackgroundBorderless"
+                        android:clickable="true"
+                        android:contentDescription="@string/contact_is_verified"
+                        android:focusable="true"
+                        android:padding="1dp"
+                        android:src="@drawable/ic_check_circle_24dp"
+                        android:visibility="gone"
+                        app:tint="?attr/colorPrimary" />
 
                     <ImageView
                         android:id="@+id/jabjab_badge_icon"
                         android:layout_width="16dp"
                         android:layout_height="16dp"
-                        android:layout_marginStart="6dp"
+                        android:layout_marginStart="4dp"
                         android:background="?attr/selectableItemBackgroundBorderless"
                         android:clickable="true"
                         android:contentDescription="@string/contact_uses_jabjab"
@@ -66,13 +70,129 @@
                         android:src="@drawable/ic_app_icon_notification"
                         android:visibility="gone"
                         app:tint="?attr/colorPrimary" />
+
+                    <TextView
+                        android:id="@+id/toolbar_status_text"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginStart="6dp"
+                        android:ellipsize="end"
+                        android:maxLines="1"
+                        android:textAppearance="?attr/textAppearanceBodySmall"
+                        android:textColor="?colorOnSurfaceVariant"
+                        android:visibility="gone" />
                 </LinearLayout>
             </com.google.android.material.appbar.MaterialToolbar>
 
         </com.google.android.material.appbar.AppBarLayout>
 
+        <!-- Telegram-style "pinned messages" banner: shows the currently focused pinned
+             message with a stack counter, tap jumps to it, chevron cycles through the stack,
+             X unpins it, long-press opens the full pinned-messages list. Hidden entirely when
+             the conversation has no pinned messages (see refreshPinnedMessagesBanner()). -->
+        <LinearLayout
+            android:id="@+id/pinned_messages_banner"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:background="?colorSurfaceContainer"
+            android:clickable="true"
+            android:focusable="true"
+            android:gravity="center_vertical"
+            android:orientation="horizontal"
+            android:paddingHorizontal="12dp"
+            android:paddingVertical="8dp"
+            android:visibility="gone">
+
+            <View
+                android:layout_width="3dp"
+                android:layout_height="match_parent"
+                android:layout_marginEnd="10dp"
+                android:background="?colorPrimary" />
+
+            <LinearLayout
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:orientation="vertical">
+
+                <TextView
+                    android:id="@+id/pinned_messages_title"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textAppearance="?textAppearanceBodySmall"
+                    android:textColor="?colorPrimary"
+                    android:textStyle="bold"
+                    tools:text="Pinned message  2/5" />
+
+                <TextView
+                    android:id="@+id/pinned_messages_snippet"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:ellipsize="end"
+                    android:maxLines="1"
+                    android:textAppearance="?textAppearanceBodyMedium"
+                    tools:text="Tommy: Wow!" />
+            </LinearLayout>
+
+            <ImageButton
+                android:id="@+id/pinned_messages_cycle_prev"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:background="?attr/selectableItemBackgroundBorderless"
+                android:contentDescription="@string/pinned_messages_previous"
+                android:padding="8dp"
+                android:src="@drawable/ic_expand_less_24dp" />
+
+            <ImageButton
+                android:id="@+id/pinned_messages_cycle"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:background="?attr/selectableItemBackgroundBorderless"
+                android:contentDescription="@string/pinned_messages_next"
+                android:padding="8dp"
+                android:src="@drawable/ic_expand_more_24dp" />
+
+            <ImageButton
+                android:id="@+id/pinned_messages_close"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:background="?attr/selectableItemBackgroundBorderless"
+                android:contentDescription="@string/unpin_message"
+                android:padding="8dp"
+                android:src="@drawable/ic_close_24dp" />
+
+        </LinearLayout>
+
+        <!-- Only shown for contacts in Config.COMMAND_CAPABLE_JIDS (see
+             ConversationFragment#updateBotCommandTabVisibility) -->
+        <com.google.android.material.tabs.TabLayout
+            android:id="@+id/bot_command_tabs"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:visibility="gone">
+
+            <com.google.android.material.tabs.TabItem
+                android:id="@+id/tab_chat"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/tab_chat" />
+
+            <com.google.android.material.tabs.TabItem
+                android:id="@+id/tab_commands"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/tab_commands" />
+
+        </com.google.android.material.tabs.TabLayout>
+
+        <FrameLayout
+            android:id="@+id/bot_commands_container"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:visibility="gone" />
 
         <RelativeLayout xmlns:tools="http://schemas.android.com/tools"
+            android:id="@+id/chat_content_container"
             android:layout_width="match_parent"
             android:layout_height="match_parent">
 
diff --git a/src/main/res/layout/item_bot_command.xml b/src/main/res/layout/item_bot_command.xml
new file mode 100644
index 0000000..90587c8
--- /dev/null
+++ b/src/main/res/layout/item_bot_command.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="?attr/selectableItemBackground"
+    android:clickable="true"
+    android:focusable="true"
+    android:orientation="vertical"
+    android:paddingHorizontal="16dp"
+    android:paddingVertical="12dp">
+
+    <TextView
+        android:id="@+id/command_name"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:ellipsize="end"
+        android:maxLines="1"
+        android:textAppearance="?attr/textAppearanceTitleMedium" />
+
+    <TextView
+        android:id="@+id/command_node"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="2dp"
+        android:ellipsize="end"
+        android:maxLines="1"
+        android:textAppearance="?attr/textAppearanceBodySmall"
+        android:textColor="?colorOnSurfaceVariant" />
+
+</LinearLayout>
diff --git a/src/main/res/layout/item_command_form_boolean.xml b/src/main/res/layout/item_command_form_boolean.xml
new file mode 100644
index 0000000..5dd94f2
--- /dev/null
+++ b/src/main/res/layout/item_command_form_boolean.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/form_field_switch"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginTop="12dp" />
diff --git a/src/main/res/layout/item_command_form_list.xml b/src/main/res/layout/item_command_form_list.xml
new file mode 100644
index 0000000..e7f1a9e
--- /dev/null
+++ b/src/main/res/layout/item_command_form_list.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginTop="12dp"
+    android:orientation="vertical">
+
+    <TextView
+        android:id="@+id/form_field_label"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textAppearance="?attr/textAppearanceBodySmall"
+        android:textColor="?colorOnSurfaceVariant" />
+
+    <Spinner
+        android:id="@+id/form_field_spinner"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+
+</LinearLayout>
diff --git a/src/main/res/layout/item_command_form_text.xml b/src/main/res/layout/item_command_form_text.xml
new file mode 100644
index 0000000..edccca0
--- /dev/null
+++ b/src/main/res/layout/item_command_form_text.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.textfield.TextInputLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginTop="12dp"
+    style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
+
+    <com.google.android.material.textfield.TextInputEditText
+        android:id="@+id/form_field_input"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+
+</com.google.android.material.textfield.TextInputLayout>
diff --git a/src/main/res/layout/item_message_end.xml b/src/main/res/layout/item_message_end.xml
index 85486f5..c781888 100644
--- a/src/main/res/layout/item_message_end.xml
+++ b/src/main/res/layout/item_message_end.xml
@@ -84,6 +84,17 @@
                         android:src="@drawable/ic_edit_24dp"
                         app:tint="?colorOnSecondaryContainer" />
 
+                    <ImageView
+                        android:id="@+id/pinned_indicator"
+                        android:layout_width="12sp"
+                        android:layout_height="12sp"
+                        android:layout_gravity="center_vertical"
+                        android:layout_marginStart="4sp"
+                        android:gravity="center_vertical"
+                        android:src="@drawable/ic_push_pin_24dp"
+                        android:visibility="gone"
+                        app:tint="?colorOnSecondaryContainer" />
+
                     <ImageView
                         android:id="@+id/indicator_received"
                         android:layout_width="16sp"
diff --git a/src/main/res/layout/item_message_start.xml b/src/main/res/layout/item_message_start.xml
index e6583eb..ae00995 100644
--- a/src/main/res/layout/item_message_start.xml
+++ b/src/main/res/layout/item_message_start.xml
@@ -96,6 +96,17 @@
                         android:src="@drawable/ic_edit_24dp"
                         app:tint="?colorOnTertiaryContainer" />
 
+                    <ImageView
+                        android:id="@+id/pinned_indicator"
+                        android:layout_width="12sp"
+                        android:layout_height="12sp"
+                        android:layout_gravity="center_vertical"
+                        android:layout_marginStart="4sp"
+                        android:gravity="center_vertical"
+                        android:src="@drawable/ic_push_pin_24dp"
+                        android:visibility="gone"
+                        app:tint="?colorOnTertiaryContainer" />
+
                     <ImageView
                         android:id="@+id/indicator_received"
                         android:layout_width="16sp"
diff --git a/src/main/res/menu/fragment_conversation.xml b/src/main/res/menu/fragment_conversation.xml
index 8f0f6e2..9ac8b05 100644
--- a/src/main/res/menu/fragment_conversation.xml
+++ b/src/main/res/menu/fragment_conversation.xml
@@ -89,6 +89,12 @@
                 android:orderInCategory="73"
                 android:title="@string/add_to_favorites"
                 app:showAsAction="never" />
+            <item
+                android:id="@+id/action_pinned_messages"
+                android:orderInCategory="74"
+                android:title="@string/pinned_messages"
+                android:visible="false"
+                app:showAsAction="never" />
         </menu>
     </item>
 
diff --git a/src/main/res/menu/message_context.xml b/src/main/res/menu/message_context.xml
index 267fd36..13f4aad 100644
--- a/src/main/res/menu/message_context.xml
+++ b/src/main/res/menu/message_context.xml
@@ -65,6 +65,17 @@
         android:title="@string/quote"
         android:visible="false" />
 
+    <item
+        android:id="@+id/action_pin_message"
+        android:icon="@drawable/ic_push_pin_24dp"
+        android:title="@string/pin_message"
+        android:visible="false" />
+    <item
+        android:id="@+id/action_unpin_message"
+        android:icon="@drawable/ic_push_pin_24dp"
+        android:title="@string/unpin_message"
+        android:visible="false" />
+
     <item
         android:id="@+id/retry_decryption"
         android:icon="@drawable/ic_lock_open_outline_24dp"
diff --git a/src/main/res/menu/pinned_message_context.xml b/src/main/res/menu/pinned_message_context.xml
new file mode 100644
index 0000000..e18fec6
--- /dev/null
+++ b/src/main/res/menu/pinned_message_context.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <item
+        android:id="@+id/action_unpin_message"
+        android:title="@string/unpin_message" />
+
+</menu>
diff --git a/src/main/res/values-cs/strings.xml b/src/main/res/values-cs/strings.xml
index 4f95370..c19f10f 100644
--- a/src/main/res/values-cs/strings.xml
+++ b/src/main/res/values-cs/strings.xml
@@ -1260,7 +1260,7 @@
     <string name="about_version">Verze %s</string>
     <string name="about_version_with_code">Verze %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Založeno na Conversations %s</string>
-    <string name="about_source_link">Zdrojový kód (Codeberg)</string>
+    <string name="about_source_link">Zdrojový kód (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations od Daniela Gultsche</string>
     <string name="about_license">Licencováno pod GNU GPL v3</string>
     <string name="about_changelog">Seznam změn</string>
@@ -1453,4 +1453,16 @@
     <string name="ephemeral_upload_always_allow">Vždy odesílat bez dotazu</string>
     <string name="story_caption_hint">Přidat popisek…</string>
     <string name="story_post_button">Publikovat</string>
+    <plurals name="delete_files_dialog">
+        <item quantity="one">Smazat soubor</item>
+        <item quantity="few">Smazat %d soubory</item>
+        <item quantity="many">Smazat %d souborů</item>
+        <item quantity="other">Smazat %d souborů</item>
+    </plurals>
+    <plurals name="delete_files_dialog_msg">
+        <item quantity="one">Jste si jisti, že chcete smazat tento soubor?</item>
+        <item quantity="few">Jste si jisti, že chcete smazat tyto soubory?</item>
+        <item quantity="many">Jste si jisti, že chcete smazat tyto soubory?</item>
+        <item quantity="other">Jste si jisti, že chcete smazat tyto soubory?</item>
+    </plurals>
 </resources>
diff --git a/src/main/res/values-da-rDK/strings.xml b/src/main/res/values-da-rDK/strings.xml
index 979bad9..ecf4324 100644
--- a/src/main/res/values-da-rDK/strings.xml
+++ b/src/main/res/values-da-rDK/strings.xml
@@ -1239,7 +1239,7 @@
     <string name="about_version">Version %s</string>
     <string name="about_version_with_code">Version %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Baseret på Conversations %s</string>
-    <string name="about_source_link">Kildekode (Codeberg)</string>
+    <string name="about_source_link">Kildekode (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations af Daniel Gultsch</string>
     <string name="about_license">Licenseret under GNU GPL v3</string>
     <string name="about_changelog">Ændringslog</string>
diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml
index 5cd61aa..503a581 100644
--- a/src/main/res/values-de/strings.xml
+++ b/src/main/res/values-de/strings.xml
@@ -1417,7 +1417,7 @@
     <string name="about_version">Version %s</string>
     <string name="about_version_with_code">Version %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Basiert auf Conversations %s</string>
-    <string name="about_source_link">Quellcode (Codeberg)</string>
+    <string name="about_source_link">Quellcode (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations von Daniel Gultsch</string>
     <string name="about_license">Lizenziert unter GNU GPL v3</string>
     <string name="about_changelog">Änderungsprotokoll</string>
diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml
index 7dc51e1..21e96dc 100644
--- a/src/main/res/values-es/strings.xml
+++ b/src/main/res/values-es/strings.xml
@@ -1333,7 +1333,7 @@
     <string name="about_version">Versión %s</string>
     <string name="about_version_with_code">Versión %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Basado en Conversations %s</string>
-    <string name="about_source_link">Código fuente (Codeberg)</string>
+    <string name="about_source_link">Código fuente (git.xmpp.tel)</string>
     <string name="about_upstream_link">Origen: Conversations de Daniel Gultsch</string>
     <string name="about_license">Bajo licencia GNU GPL v3</string>
     <string name="about_changelog">Registro de cambios</string>
diff --git a/src/main/res/values-et/strings.xml b/src/main/res/values-et/strings.xml
index 9ca222f..e47cbca 100644
--- a/src/main/res/values-et/strings.xml
+++ b/src/main/res/values-et/strings.xml
@@ -1333,7 +1333,7 @@
     <string name="about_version">Versioon %s</string>
     <string name="about_version_with_code">Versioon %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Põhineb rakendusel Conversations %s</string>
-    <string name="about_source_link">Lähtekood (Codeberg)</string>
+    <string name="about_source_link">Lähtekood (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations, autor Daniel Gultsch</string>
     <string name="about_license">Litsentseeritud GNU GPL v3 alusel</string>
     <string name="about_changelog">Muudatuste logi</string>
diff --git a/src/main/res/values-fi/strings.xml b/src/main/res/values-fi/strings.xml
index f63f2f0..a9be121 100644
--- a/src/main/res/values-fi/strings.xml
+++ b/src/main/res/values-fi/strings.xml
@@ -1236,7 +1236,7 @@
     <string name="about_version">Versio %s</string>
     <string name="about_version_with_code">Versio %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Perustuu Conversations-sovellukseen %s</string>
-    <string name="about_source_link">Lähdekoodi (Codeberg)</string>
+    <string name="about_source_link">Lähdekoodi (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations, Daniel Gultsch</string>
     <string name="about_license">Lisensoitu GNU GPL v3 -lisenssillä</string>
     <string name="about_changelog">Muutosloki</string>
diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml
index c5fb814..ae84cdb 100644
--- a/src/main/res/values-fr/strings.xml
+++ b/src/main/res/values-fr/strings.xml
@@ -1291,7 +1291,7 @@
     <string name="about_version">Version %s</string>
     <string name="about_version_with_code">Version %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Basé sur Conversations %s</string>
-    <string name="about_source_link">Code source (Codeberg)</string>
+    <string name="about_source_link">Code source (git.xmpp.tel)</string>
     <string name="about_upstream_link">Source : Conversations par Daniel Gultsch</string>
     <string name="about_license">Sous licence GNU GPL v3</string>
     <string name="about_changelog">Journal des modifications</string>
diff --git a/src/main/res/values-gl/strings.xml b/src/main/res/values-gl/strings.xml
index 92161f1..b639ced 100644
--- a/src/main/res/values-gl/strings.xml
+++ b/src/main/res/values-gl/strings.xml
@@ -1202,4 +1202,11 @@
     <string name="tab_joined_channels">Unidos</string>
     <string name="tab_discover_channels">Descubrir</string>
     <string name="no_joined_channels_hint">Aínda non hai canles nin chats de grupo.\nUsa a pestana Descubrir para atopar e unirte a canles públicas.</string>
+    <string name="delete_file_dialog_shared">Compartiuse este ficheiro con outras conversas.</string>
+    <string name="delete_file_dialog_confirm">Tes certeza de querer eliminar este ficheiro?</string>
+    <string name="delete_file_dialog_copies">Isto non vai eliminar as copias do ficheiro que se gardan noutros dispositivos ou servidores.</string>
+    <string name="sheet_music">partitura</string>
+    <string name="attachment_choice_contact">Contacto</string>
+    <string name="note_to_self">Nota persoal</string>
+    <string name="invalid_contact">Non se puido ler o contacto</string>
 </resources>
diff --git a/src/main/res/values-it/strings.xml b/src/main/res/values-it/strings.xml
index b5a635d..e17e7f0 100644
--- a/src/main/res/values-it/strings.xml
+++ b/src/main/res/values-it/strings.xml
@@ -1325,7 +1325,7 @@
     <string name="about_version">Versione %s</string>
     <string name="about_version_with_code">Versione %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Basato su Conversations %s</string>
-    <string name="about_source_link">Codice sorgente (Codeberg)</string>
+    <string name="about_source_link">Codice sorgente (git.xmpp.tel)</string>
     <string name="about_upstream_link">Origine: Conversations di Daniel Gultsch</string>
     <string name="about_license">Distribuito con licenza GNU GPL v3</string>
     <string name="about_changelog">Registro modifiche</string>
diff --git a/src/main/res/values-night/themes.xml b/src/main/res/values-night/themes.xml
index 9c915e6..6fd578c 100644
--- a/src/main/res/values-night/themes.xml
+++ b/src/main/res/values-night/themes.xml
@@ -49,7 +49,7 @@
     </style>
 
     <style name="ThemeOverlay.Conversations3.Sunset" parent="">
-        <item name="colorPrimary">#FFB78B</item>
+        <item name="colorPrimary">#EC8F55</item>
         <item name="colorOnPrimary">#4D1B00</item>
         <item name="colorPrimaryContainer">#6D2900</item>
         <item name="colorOnPrimaryContainer">#FFDBCA</item>
@@ -61,10 +61,10 @@
         <item name="colorOnSecondaryContainer">#FFDBCA</item>
         <item name="colorTertiary">#C8C240</item>
         <item name="colorOnTertiary">#313000</item>
-        <item name="colorTertiaryContainer">#474600</item>
-        <item name="colorOnTertiaryContainer">#E3E04D</item>
+        <item name="colorTertiaryContainer">#DD952C</item>
+        <item name="colorOnTertiaryContainer">#3E3D02</item>
         <item name="colorSurfaceContainer">#211510</item>
-        <item name="colorSurfaceContainerHigh">#271A14</item>
+        <item name="colorSurfaceContainerHigh">#422416</item>
     </style>
 
     <style name="ThemeOverlay.Conversations3.Purple" parent="">
diff --git a/src/main/res/values-nl/strings.xml b/src/main/res/values-nl/strings.xml
index 65a3fd5..332cbc8 100644
--- a/src/main/res/values-nl/strings.xml
+++ b/src/main/res/values-nl/strings.xml
@@ -1232,7 +1232,7 @@
     <string name="about_version">Versie %s</string>
     <string name="about_version_with_code">Versie %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Gebaseerd op Conversations %s</string>
-    <string name="about_source_link">Broncode (Codeberg)</string>
+    <string name="about_source_link">Broncode (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations door Daniel Gultsch</string>
     <string name="about_license">Gelicentieerd onder GNU GPL v3</string>
     <string name="about_changelog">Wijzigingslog</string>
diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml
index f747135..ac4f526 100644
--- a/src/main/res/values-pl/strings.xml
+++ b/src/main/res/values-pl/strings.xml
@@ -1347,7 +1347,7 @@
     <string name="about_version">Wersja %s</string>
     <string name="about_version_with_code">Wersja %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Oparte na Conversations %s</string>
-    <string name="about_source_link">Kod źródłowy (Codeberg)</string>
+    <string name="about_source_link">Kod źródłowy (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations autorstwa Daniela Gultscha</string>
     <string name="about_license">Na licencji GNU GPL v3</string>
     <string name="about_changelog">Lista zmian</string>
diff --git a/src/main/res/values-pt-rBR/strings.xml b/src/main/res/values-pt-rBR/strings.xml
index 870fbf8..0311411 100644
--- a/src/main/res/values-pt-rBR/strings.xml
+++ b/src/main/res/values-pt-rBR/strings.xml
@@ -1313,7 +1313,7 @@
     <string name="about_version">Versão %s</string>
     <string name="about_version_with_code">Versão %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Baseado no Conversations %s</string>
-    <string name="about_source_link">Código-fonte (Codeberg)</string>
+    <string name="about_source_link">Código-fonte (git.xmpp.tel)</string>
     <string name="about_upstream_link">Origem: Conversations por Daniel Gultsch</string>
     <string name="about_license">Licenciado sob a GNU GPL v3</string>
     <string name="about_changelog">Registro de alterações</string>
diff --git a/src/main/res/values-pt/strings.xml b/src/main/res/values-pt/strings.xml
index fcfe89a..1a64023 100644
--- a/src/main/res/values-pt/strings.xml
+++ b/src/main/res/values-pt/strings.xml
@@ -1324,7 +1324,7 @@
     <string name="about_version">Versão %s</string>
     <string name="about_version_with_code">Versão %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Baseado no Conversations %s</string>
-    <string name="about_source_link">Código-fonte (Codeberg)</string>
+    <string name="about_source_link">Código-fonte (git.xmpp.tel)</string>
     <string name="about_upstream_link">Origem: Conversations por Daniel Gultsch</string>
     <string name="about_license">Licenciado sob a GNU GPL v3</string>
     <string name="about_changelog">Registo de alterações</string>
diff --git a/src/main/res/values-ro-rRO/strings.xml b/src/main/res/values-ro-rRO/strings.xml
index e47b290..b3db34a 100644
--- a/src/main/res/values-ro-rRO/strings.xml
+++ b/src/main/res/values-ro-rRO/strings.xml
@@ -1335,7 +1335,7 @@
     <string name="about_version">Versiunea %s</string>
     <string name="about_version_with_code">Versiunea %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Bazat pe Conversations %s</string>
-    <string name="about_source_link">Cod sursă (Codeberg)</string>
+    <string name="about_source_link">Cod sursă (git.xmpp.tel)</string>
     <string name="about_upstream_link">Sursă: Conversations de Daniel Gultsch</string>
     <string name="about_license">Licențiat sub GNU GPL v3</string>
     <string name="about_changelog">Jurnal de modificări</string>
diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml
index 876b040..23ce4de 100644
--- a/src/main/res/values-ru/strings.xml
+++ b/src/main/res/values-ru/strings.xml
@@ -1370,7 +1370,7 @@
     <string name="about_version">Версия %s</string>
     <string name="about_version_with_code">Версия %1$s (%2$d)</string>
     <string name="about_based_on_conversations">На основе Conversations %s</string>
-    <string name="about_source_link">Исходный код (Codeberg)</string>
+    <string name="about_source_link">Исходный код (git.xmpp.tel)</string>
     <string name="about_upstream_link">Основа: Conversations от Daniel Gultsch</string>
     <string name="about_license">Лицензировано под GNU GPL v3</string>
     <string name="about_changelog">Список изменений</string>
diff --git a/src/main/res/values-sr/strings.xml b/src/main/res/values-sr/strings.xml
index 1d7a0c7..ca792b1 100644
--- a/src/main/res/values-sr/strings.xml
+++ b/src/main/res/values-sr/strings.xml
@@ -1352,7 +1352,7 @@
     <string name="about_version">Verzija %s</string>
     <string name="about_version_with_code">Verzija %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Zasnovano na Conversations %s</string>
-    <string name="about_source_link">Izvorni kod (Codeberg)</string>
+    <string name="about_source_link">Izvorni kod (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations, autor Daniel Gultsch</string>
     <string name="about_license">Licencirano pod GNU GPL v3</string>
     <string name="about_changelog">Spisak izmena</string>
diff --git a/src/main/res/values-sv/strings.xml b/src/main/res/values-sv/strings.xml
index cc87a8c..1a486f2 100644
--- a/src/main/res/values-sv/strings.xml
+++ b/src/main/res/values-sv/strings.xml
@@ -1328,7 +1328,7 @@
     <string name="about_version">Version %s</string>
     <string name="about_version_with_code">Version %1$s (%2$d)</string>
     <string name="about_based_on_conversations">Baserad på Conversations %s</string>
-    <string name="about_source_link">Källkod (Codeberg)</string>
+    <string name="about_source_link">Källkod (git.xmpp.tel)</string>
     <string name="about_upstream_link">Upstream: Conversations av Daniel Gultsch</string>
     <string name="about_license">Licensierad under GNU GPL v3</string>
     <string name="about_changelog">Ändringslogg</string>
diff --git a/src/main/res/values-tr-rTR/strings.xml b/src/main/res/values-tr-rTR/strings.xml
index c3e06ba..7b18659 100644
--- a/src/main/res/values-tr-rTR/strings.xml
+++ b/src/main/res/values-tr-rTR/strings.xml
@@ -90,7 +90,7 @@
     <string name="send_unencrypted">Açık metin olarak gönder</string>
     <string name="decryption_failed">Deşifre edilemedi. Uygun bir özel anahtarınız olmayabilir.</string>
     <string name="openkeychain_required">OpenKeychain</string>
-    <string name="openkeychain_required_long"><![CDATA[%1$s mesajları şifrelemek, deşifre etmek ve genel anahtarlarınızı yönetmek için <b>OpenKeychain</b> kullanmaktadır.<br><br>lt GPLv3+ altında lisanslıdır ve F-Droid ve Google Play\'de mevcuttur.<br><br><small>(Lütfen devamında %1$s\'ı yeniden başlatın.)</small>]]></string>
+    <string name="openkeychain_required_long"><![CDATA[%1$s mesajları şifrelemek, deşifre etmek ve genel anahtarlarınızı yönetmek için <b>OpenKeychain</b> kullanmaktadır.<br><br>GPLv3+ altında lisanslıdır ve F-Droid ve Google Play\'de mevcuttur.<br><br><small>(Lütfen devamında %1$s\'ı yeniden başlatın.)</small>]]></string>
     <string name="restart">Yeniden başlat</string>
     <string name="install">Kur</string>
     <string name="openkeychain_not_installed">Lütfen OpenKeychain’i kur</string>
@@ -118,7 +118,7 @@
     <string name="pref_advanced_options">Gelişmiş</string>
     <string name="pref_never_send_crash_summary">Yığın izi göndererek gelişime yardımcı oluyorsunuz</string>
     <string name="pref_confirm_messages">İletileri onayla</string>
-    <string name="pref_confirm_messages_summary">Onların iletilerini aldığınızda ve okuduğunuzda, kişilerinizin bunu bilmesini sağlayın</string>
+    <string name="pref_confirm_messages_summary">Etkinleştirildiğinde, kişileriniz mesajları okuduğunuzu görür.</string>
     <string name="pref_prevent_screenshots">Ekran görüntülerini engelle</string>
     <string name="pref_prevent_screenshots_summary">Uygulama anahtarlayıcısında uygulama içeriklerini sakla ve ekran görüntülerini engelle</string>
     <string name="pref_ui_options">Arabirim</string>
@@ -147,7 +147,7 @@
     <string name="account_status_offline">Çevrim dışı</string>
     <string name="account_status_unauthorized">Yetkisiz</string>
     <string name="account_status_not_found">Sunucu bulunamadı</string>
-    <string name="account_status_no_internet">Bağlantı yok</string>
+    <string name="account_status_no_internet">İnternet yok</string>
     <string name="account_status_regis_fail">Hesap oluşturulamadı</string>
     <string name="account_status_regis_conflict">Kullanıcı adı kullanılıyor</string>
     <string name="account_status_regis_success">Hesap oluşturuldu</string>
@@ -269,7 +269,7 @@
     <string name="pref_allow_message_correction_summary">Kişilerinizin geçmiş iletilerini düzeltmelerine izin ver</string>
     <string name="pref_expert_options">Uzman seçenekleri</string>
     <string name="pref_expert_options_summary">Lütfen dikkatli olun</string>
-    <string name="title_activity_about_x">%sHakkında</string>
+    <string name="title_activity_about_x">%s Hakkında</string>
     <string name="title_pref_quiet_hours">Sessiz Saatler</string>
     <string name="title_pref_quiet_hours_start_time">Başlangıç zamanı</string>
     <string name="title_pref_quiet_hours_end_time">Bitiş zamanı</string>
@@ -414,8 +414,8 @@
     <string name="dialog_manage_certs_title">Sertifikaları kaldır</string>
     <string name="dialog_manage_certs_positivebutton">Seçilenleri sil</string>
     <plurals name="toast_delete_certificates">
-        <item quantity="one">%d sertifikaları silindi</item>
-        <item quantity="other">%d sertifikaları silindi</item>
+        <item quantity="one">%d sertifika silindi</item>
+        <item quantity="other">%d sertifika silindi</item>
     </plurals>
     <string name="pref_quick_action">Kısayol</string>
     <string name="choose_quick_action">Kısayolu seç</string>
@@ -493,8 +493,8 @@
     <string name="selection_too_large">Seçilen alan çok büyük</string>
     <string name="no_accounts">(Etkin hesap bulunmuyor)</string>
     <string name="this_field_is_required">Bu alan zorunludur</string>
-    <string name="correct_message">ileti düzelt</string>
-    <string name="send_corrected_message">Düzeltilmiş iletiyi gönder</string>
+    <string name="correct_message">Düzenle</string>
+    <string name="send_corrected_message">Düzenlenmiş iletiyi gönder</string>
     <string name="no_keys_just_confirm">Bu kişinin parmak izine zaten güvenmiştiniz. \"Tamam\"ı seçerek sadece %s\'in bu grupta olduğunu onaylamış olacaksınız.</string>
     <string name="this_account_is_disabled">Bu hesabı devre dışı bıraktınız</string>
     <string name="security_error_invalid_file_access">Güvenlik hatası: Geçersiz dosya erişimi!</string>
@@ -508,13 +508,13 @@
     <string name="use_own_provider">Kendi sağlayıcımı kullan</string>
     <string name="pick_your_username">Kullanıcı adınızı seçin</string>
     <string name="pref_manually_change_presence">Mevcudiyet durumunu elle yönet</string>
-    <string name="pref_manually_change_presence_summary">Durumunuzu düzenlerken mevcudiyetinizi kaydedin</string>
+    <string name="pref_manually_change_presence_summary">Durumunuzu düzenlerken mevcudiyetinizi kaydedin.</string>
     <string name="status_message">Durum iletisi</string>
     <string name="presence_chat">Konuşmak için uygun</string>
     <string name="presence_online">Çevrim içi</string>
     <string name="presence_away">Uzakta</string>
     <string name="presence_xa">Mevcut Değil</string>
-    <string name="presence_dnd">Meşgul</string>
+    <string name="presence_dnd">Rahatsız Etme</string>
     <string name="secure_password_generated">Güvenli bir parola oluşturuldu</string>
     <string name="device_does_not_support_battery_op">Aygıtınız pil iyileştirmesini devre dışı bırakmayı desteklemiyor</string>
     <string name="registration_please_wait">Hesap oluşturulamadı: Sonra yeniden deneyin</string>
@@ -565,9 +565,9 @@
     <string name="copy_fingerprint">Parmak izini kopyala</string>
     <string name="all_omemo_keys_have_been_verified">Sahip olduğunuz bütün OMEMO anahtarlarını doğruladınız</string>
     <string name="verified_fingerprints">Doğrulanmış parmak izleri</string>
-    <string name="use_camera_icon_to_scan_barcode">Bir kişinin barkodunu taramak için kamerayı kullan</string>
+    <string name="use_camera_icon_to_scan_barcode">Bir kişinin QR kodunu taramak için kamerayı kullan</string>
     <string name="please_wait_for_keys_to_be_fetched">Anahtarların alınması için lütfen bekle</string>
-    <string name="share_as_uri">XMPP URI\'si olarak paylaş</string>
+    <string name="share_as_uri">XMPP URI\'si paylaş</string>
     <string name="pref_blind_trust_before_verification">Doğrulamadan Önce Kör Güven</string>
     <string name="pref_blind_trust_before_verification_summary">Doğrulanmamış kişilerin tüm yani aygıtlarına güven, ama doğrulanmış kişilerden aygıtlarını onaylamalarını iste.</string>
     <string name="blindly_trusted_omemo_keys">OMEMO anahtarlarına körlemisne güvenildi, yani bu kişi bir başkası olabilir veya biri konuşmayı dinleyebilir.</string>
@@ -637,7 +637,7 @@
     <string name="pref_validate_hostname_summary">Doğrulanmış sunucu adına sahip sunucu sertifikaları onaylanmış kabul edilir</string>
     <string name="certificate_does_not_contain_jid">Sertifika bir XMPP adresi içermiyor</string>
     <string name="server_info_partial">kısmi</string>
-    <string name="copy_to_clipboard">Panoya kopyala</string>
+    <string name="copy_to_clipboard">Kopyala</string>
     <string name="message_copied_to_clipboard">İleti panoya kopyalandı</string>
     <string name="message">İleti</string>
     <string name="private_messages_are_disabled">Özel iletiler devre dışı bırakıldı</string>
@@ -728,7 +728,7 @@
     <string name="security_violation_not_attaching_file">Dosya güvenlik ihlalinden dolayı dahil edilmedi.</string>
     <string name="pref_video_compression">Video kalitesi</string>
     <string name="pref_video_compression_summary">Daha düşük kalite, daha ufak dosya anlamına gelir</string>
-    <string name="video_360p">Orta (360P)</string>
+    <string name="video_360p">Düşük (360P)</string>
     <string name="video_720p">Yüksek (720p)</string>
     <string name="cancelled">iptal edildi</string>
     <string name="already_drafting_message">Zaten taslak halinde bir iletiniz var.</string>
@@ -837,13 +837,13 @@
     <string name="conversations_backup">Conversations yedeklemesi</string>
     <string name="event">Faaliyet</string>
     <string name="open_backup">Yedeklemeyi aç</string>
-    <string name="not_a_backup_file">Seçtioiniz dosya bir Conversations yedekleme dosyası değildir</string>
+    <string name="not_a_backup_file">Seçtiğiniz dosya bir Conversations yedekleme dosyası değildir</string>
     <string name="account_already_setup">Bu hesap zaten kurulu vaziyette</string>
     <string name="please_enter_password">Lütfen bu hesabın şifresini girin</string>
     <string name="unable_to_perform_this_action">Eylem gerçekleştirilemedi</string>
     <string name="open_join_dialog">Ortak kanala katılınıyor…</string>
     <string name="sharing_application_not_grant_permission">Paylaşımda bulunan uygulama dosya erişimi için yetki sağlamıyor.</string>
-    <string name="group_chats_and_channels"><![CDATA[Grup Konuşmaları & Kanallar]]></string>
+    <string name="group_chats_and_channels"><![CDATA[Grup Konuşmaları &amp; Kanallar]]></string>
     <string name="jabber_network">jabber.network</string>
     <string name="local_server">Yerel sunucu</string>
     <string name="pref_channel_discovery_summary">XMPP ortamının genelinde daha iyi öneriler almak için çoğu kullanıcı \'jabber.network\'\'ü seçmelidir.</string>
@@ -900,7 +900,7 @@
     <string name="add_to_favorites">En başa iliştir</string>
     <string name="remove_from_favorites">En baştan kaldır</string>
     <string name="gpx_track">GPX izi</string>
-    <string name="could_not_correct_message">İleti düzeltilemedi</string>
+    <string name="could_not_correct_message">İleti düzenlenemedi</string>
     <string name="your_avatar">Avatarınız</string>
     <string name="avatar_for_x">%s avatarı</string>
     <string name="encrypted_with_omemo">OMEMO ile şifrelendi</string>
@@ -1017,7 +1017,7 @@
     <string name="pref_autojoin_summary">Bir gruba katılırken \"autojoin\" (otomatik katıl) işaretini ayarla ve diğer istemciler tarafından yapılan değişikliklere tepki ver.</string>
     <string name="title_undo_swipe_out_chat">Sohbet arşivlendi</string>
     <string name="search_group_chats">Grup ara</string>
-    <string name="barcode_does_not_contain_fingerprints_for_this_chat">Barkod, bu sohbet için parmak izi bilgisi barındırmıyor.</string>
+    <string name="barcode_does_not_contain_fingerprints_for_this_chat">QR kod, bu sohbet için parmak izi bilgisi barındırmıyor.</string>
     <string name="channel_discover_opt_in_message">Grup keşfetme &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt; adlı bir hizmeti kullanır.&lt;br&gt;&lt;br&gt;Bu özelliği kullanmanız IP adresinizi ve aramalarınızı bu hizmete gönderecektir. Daha fazla bilgi için hizmetin &lt;a href=https://search.jabber.network/privacy&gt;Gizlilik Politikasına&lt;/a&gt; göz atın.</string>
     <string name="outdated_backup_file_format">Artık desteklenmeyen bir yedek dosyası türünü geri yüklemeye çalışıyorsunuz</string>
     <string name="pref_summary_security">Uçtan Uca Şifreleme, Doğrulamadan Körü Körüne Güven, MITM Algılama</string>
@@ -1046,7 +1046,7 @@
     <string name="copy_telephone_number">Telefon numarasını kopyala</string>
     <string name="copied_phone_number">Telefon numarası geçici taşıma panosuna kopyalandı</string>
     <string name="could_not_disable_video">Video etkisizleştirilemedi.</string>
-    <string name="pref_backup_summary">Bir defaya mahsus yarat, Yinelemeyi zamanla</string>
+    <string name="pref_backup_summary">Bir defaya mahsus yarat, Yinelemeyi zamanla, Depolama konumu</string>
     <string name="account_status_channel_binding">Kanal bağlama yok</string>
     <string name="your_avatar_tap_to_select_new_avatar">Profil resminiz. Galeriden yeni profil resmi seçmek için dokunun.</string>
     <string name="server_info_bind2">XEP-0386: Bağlantı 2</string>
@@ -1126,4 +1126,81 @@
     <string name="tab_joined_channels">Katıldığım</string>
     <string name="tab_discover_channels">Keşfet</string>
     <string name="no_joined_channels_hint">Henüz kanal veya grup sohbeti yok.\nGenel kanallara katılmak için Keşfet sekmesini kullanın.</string>
+    <string name="start_group_chat">Grup sohbeti başlat</string>
+    <string name="pref_category_backup_storage">Depolama</string>
+    <string name="pref_import_backup_summary">Verilerinizi geri getirmek için bir yedek dosyası seçin</string>
+    <string name="backup_and_restore">Yedekleme &amp; Geri Yükleme</string>
+    <string name="search_in_chats">Sohbetlerde ara</string>
+    <string name="search_for_x_in_chats">Sohbetlerde \"%s\" için ara</string>
+    <string name="pref_entity_time">Yerel zamanı paylay</string>
+    <string name="pref_entity_time_summary">Kişilerinizle mevcut saatinizi paylaşın, karşılığında onların yerel saatlerini öğrenin.</string>
+    <string name="pref_save_to_gallery">Galeriye Kaydet</string>
+    <string name="pref_save_to_gallery_summary">Medya dosyalarını, galeri ve dosya yöneticinizde görünmeleri için herkese açık klasörlere otomatik olarak kaydeder.</string>
+    <string name="video_480p">Orta (480p)</string>
+    <string name="video_1080p">Çok yüksek (1080p)</string>
+    <string name="pref_auto_send_recording">Kayıtları otomatik gönder</string>
+    <string name="pref_auto_send_recording_summary">Kayıt bittiğinde sesli mesajları otomatik gönder.</string>
+    <string name="attachment_choice_recording">Kayıt</string>
+    <string name="attachment_choice_location">Konum</string>
+    <string name="attachment_choice_camera">Kamera</string>
+    <string name="attachment_choice_video">Video</string>
+    <string name="attachment_choice_gallery">Galeri</string>
+    <string name="filter_type_other">Diğer</string>
+    <string name="select_all">Hepsini seç</string>
+    <string name="delete_file_dialog_confirm">Bu dosyayı silmek istediğinizden emin misiniz?</string>
+    <plurals name="delete_files_dialog">
+        <item quantity="one">Dosyayı sil</item>
+        <item quantity="other">%d dosyayı sil</item>
+    </plurals>
+    <plurals name="delete_files_dialog_msg">
+        <item quantity="one">Bu dosyayı silmek istediğinizden emin misiniz?</item>
+        <item quantity="other">Bu dosyaları silmek istediğinizden emin misiniz?</item>
+    </plurals>
+    <string name="account_status_airplane_mode">Uçak modu</string>
+    <string name="account_status_regis_invalid_captcha">Geçersiz CAPTCHA</string>
+    <string name="contact_has_paused_their_notifications">%s bildirimlerini geçici olarak durdurdu</string>
+    <string name="presence_offline">Çevrim dışı</string>
+    <string name="share_invitation">Davet gönder</string>
+    <string name="send">Gönder</string>
+    <string name="enable_account_to_delete">Bu hesap devre dışı bırakıldı. Sunucudan silmek için etkinleştirin.</string>
+    <string name="rtp_state_no_internet">İnternet bağlantısı yok</string>
+    <string name="server_invite">Sunucuya davet et</string>
+    <string name="server_info_stanzas_rx_tx">Stanzas (rx/tx)</string>
+    <string name="server_info_stanzas_rx_tx_push">Stanzas (rx/tx/push)</string>
+    <string name="number_reactions_are_restricted">Tepki limitine ulaşıldı.</string>
+    <string name="this_reaction_isnt_allowed">Bu tepkiye izin verilmiyor.</string>
+    <string name="emoji_reactions">Emoji tepkiler</string>
+    <string name="open_keyboard">Klavyeyi aç</string>
+    <string name="qr_code_actions">QR tara, QR göster, Sunucu daveti</string>
+    <string name="qr_code_contains_link">Web Bağlantısını Aç?</string>
+    <string name="qr_code_contains_link_text">Bu QR kod bir web sitesini işaret ediyor. Lütfen tarayıcınızda açmadan önce aşağıdaki adresi doğrulayınız:</string>
+    <string name="open_browser">Tarayıcıyı Aç</string>
+    <string name="muc_user_ban_explainer">Bu kullanıcı artık yeni mesajları göremeyecek veya sohbete katılamayacak.</string>
+    <string name="publish_qr_code">QR kod yayınla</string>
+    <string name="give_membership">Üyelik ver</string>
+    <string name="remove_membership">Üyelikten çıkar</string>
+    <string name="make_admin">Yönetici yap</string>
+    <string name="dismiss_as_admin">Yönetici olarak reddet</string>
+    <string name="make_owner">Sahip yap</string>
+    <string name="dismiss_as_owner">Sahip olarak reddet</string>
+    <string name="pref_include_silent_modes">Sessiz modları dahil et</string>
+    <plurals name="attachments_saved">
+        <item quantity="one">%d medya dosyası kaydedildi</item>
+        <item quantity="other">%d medya dosyası kaydedildi</item>
+    </plurals>
+    <string name="could_not_save_files">Dosyalar kaydedilemedi</string>
+    <plurals name="delete_n_messages">
+        <item quantity="one">%d iletiyi sil</item>
+        <item quantity="other">%d iletiyi sil</item>
+    </plurals>
+    <string name="filter_type_images">Görseller</string>
+    <string name="filter_type_videos">Videolar</string>
+    <string name="filter_type_audio">Ses</string>
+    <string name="filter_type_documents">Belgeler</string>
+    <string name="filter_media">Medyayı filtrele</string>
+    <string name="apply_filter">Uygula</string>
+    <string name="attachment_choice_file">Dosya</string>
+    <string name="attachment_choice_contact">Kişi</string>
+    <string name="note_to_self">Kendime not</string>
+    <string name="invalid_contact">Kişi çözümlenemedi</string>
 </resources>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index bfc8afb..0924067 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -639,6 +639,18 @@
     <string name="presence_dnd">Do Not Disturb</string>
     <string name="presence_offline">Offline</string>
     <string name="contact_uses_jabjab">Uses JabJab</string>
+    <string name="contact_is_verified">Verified</string>
+    <string name="contact_is_verified_toast">This is a verified account</string>
+    <string name="tab_chat">Chat</string>
+    <string name="tab_commands">Commands</string>
+    <string name="commands_loading">Loading commands…</string>
+    <string name="commands_empty">No commands available</string>
+    <string name="commands_load_failed">Could not load commands</string>
+    <string name="command_cancelled">Command cancelled</string>
+    <string name="command_completed">Command completed</string>
+    <string name="command_result">Result</string>
+    <string name="comma_separated_values">Comma-separated values</string>
+    <string name="field_required">Required</string>
     <string name="contact_uses_jabjab_toast">Your contact uses JabJab</string>
     <string name="secure_password_generated">A secure password has been generated</string>
     <string name="device_does_not_support_battery_op">Your device does not support opting out of battery optimization</string>
@@ -665,7 +677,7 @@
     <string name="pref_color_theme_amoled">AMOLED black</string>
     <string name="pref_color_theme_ocean">Ocean (blue)</string>
     <string name="pref_color_theme_forest">Forest (green)</string>
-    <string name="pref_color_theme_sunset">Sunset (warm)</string>
+    <string name="pref_color_theme_sunset">Sunset</string>
     <string name="pref_color_theme_purple">Purple</string>
     <string name="pref_color_theme_rose">Rose (red/pink)</string>
     <string name="pref_use_colorful_bubbles">Colorful chat bubbles</string>
@@ -1080,6 +1092,14 @@
     <string name="could_not_switch_camera">Could not switch camera</string>
     <string name="add_to_favorites">Pin to top</string>
     <string name="remove_from_favorites">Unpin from top</string>
+    <string name="pin_message">Pin message</string>
+    <string name="unpin_message">Unpin message</string>
+    <string name="pinned_message">Pinned message</string>
+    <string name="pinned_messages">Pinned messages</string>
+    <string name="pinned_messages_next">Show next pinned message</string>
+    <string name="pinned_messages_previous">Show previous pinned message</string>
+    <string name="pinned_messages_counter">%1$d/%2$d</string>
+    <string name="no_pinned_messages">No pinned messages</string>
     <string name="gpx_track">GPX track</string>
     <string name="could_not_correct_message">Could not edit message</string>
     <string name="your_avatar">Your avatar</string>
diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml
index d75f8d6..e80759a 100644
--- a/src/main/res/values/themes.xml
+++ b/src/main/res/values/themes.xml
@@ -166,7 +166,7 @@
         <item name="colorOnSecondaryContainer">#2C160B</item>
         <item name="colorTertiary">#5C5A00</item>
         <item name="colorOnTertiary">#FFFFFF</item>
-        <item name="colorTertiaryContainer">#E3E04D</item>
+        <item name="colorTertiaryContainer">#FFFED6</item>
         <item name="colorOnTertiaryContainer">#1B1B00</item>
         <item name="colorSurfaceContainer">#F9EEE9</item>
         <item name="colorSurfaceContainerHigh">#F5EAE4</item>
diff --git a/src/quicksy/res/values-tr-rTR/strings.xml b/src/quicksy/res/values-tr-rTR/strings.xml
index 4d81ec7..7186ad9 100644
--- a/src/quicksy/res/values-tr-rTR/strings.xml
+++ b/src/quicksy/res/values-tr-rTR/strings.xml
@@ -9,4 +9,5 @@
     <string name="unable_to_verify_server_identity">Sunucu kimliği belirlenemiyor.</string>
     <string name="unknown_security_error">Bilinmeyen güvenlik hatası.</string>
     <string name="timeout_while_connecting_to_server">Sunucuya bağlanılırken zaman aşımına uğrandı.</string>
+    <string name="use_physical_device">Lütfen hesap oluşturmak için fiziksel bir cihaz kullanınız.</string>
 </resources>

Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.

Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.