From 06abc6ff8ed5466b9241310700393bf34ee91ecd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:28:19 -0400 Subject: [PATCH 01/37] fix(strings): render "100%" not "100%%" in relay uptime and percent labels Compose resources substitute only %N$s / %N$d and never unescape %%, so the Android printf escape rendered literally ("100%% uptime", "12%% of all"). Unescape uptime, active_subs_share and podcast_value_split_percent in every locale, teach the Compose escaping check to flag %% (Crowdin reintroduces Android escapes on sync) and fix_escapes.py to repair it. Also fix the uptime icon tint: successRate is 0..100 but was compared with 0.1 / 0.60, so every relay below 1% went red and everything else green. Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/compose_escaping_check.py | 5 +++++ .../screen/loggedIn/relays/common/RelayStatusRow.kt | 4 ++-- .../composeResources/values-ar-rSA/strings.xml | 2 +- .../composeResources/values-bn-rBD/strings.xml | 2 +- .../composeResources/values-cs/strings.xml | 6 +++--- .../composeResources/values-de-rDE/strings.xml | 6 +++--- .../composeResources/values-de/strings.xml | 2 +- .../composeResources/values-el-rGR/strings.xml | 2 +- .../composeResources/values-eo-rUY/strings.xml | 2 +- .../composeResources/values-eo/strings.xml | 2 +- .../composeResources/values-es-rES/strings.xml | 2 +- .../composeResources/values-es-rMX/strings.xml | 2 +- .../composeResources/values-es-rUS/strings.xml | 2 +- .../composeResources/values-es/strings.xml | 2 +- .../composeResources/values-fa-rIR/strings.xml | 2 +- .../composeResources/values-fa/strings.xml | 2 +- .../composeResources/values-fi-rFI/strings.xml | 2 +- .../composeResources/values-fr-rCA/strings.xml | 2 +- .../composeResources/values-fr-rFR/strings.xml | 2 +- .../composeResources/values-fr/strings.xml | 2 +- .../composeResources/values-hi-rIN/strings.xml | 2 +- .../composeResources/values-hu-rHU/strings.xml | 6 +++--- .../composeResources/values-in/strings.xml | 2 +- .../composeResources/values-it-rIT/strings.xml | 2 +- .../composeResources/values-ja-rJP/strings.xml | 2 +- .../composeResources/values-ja/strings.xml | 2 +- .../composeResources/values-ko-rKR/strings.xml | 2 +- .../composeResources/values-lv-rLV/strings.xml | 2 +- .../composeResources/values-nl-rBE/strings.xml | 2 +- .../composeResources/values-nl-rNL/strings.xml | 6 +++--- .../composeResources/values-nl/strings.xml | 2 +- .../composeResources/values-pl-rPL/strings.xml | 6 +++--- .../composeResources/values-pt-rBR/strings.xml | 6 +++--- .../composeResources/values-pt-rPT/strings.xml | 2 +- .../composeResources/values-ru-rRU/strings.xml | 2 +- .../composeResources/values-ru-rUA/strings.xml | 2 +- .../composeResources/values-ru/strings.xml | 2 +- .../composeResources/values-sl-rSI/strings.xml | 6 +++--- .../composeResources/values-sr-rSP/strings.xml | 2 +- .../composeResources/values-sv-rSE/strings.xml | 6 +++--- .../composeResources/values-sw/strings.xml | 2 +- .../composeResources/values-ta-rIN/strings.xml | 2 +- .../composeResources/values-ta/strings.xml | 2 +- .../composeResources/values-th-rTH/strings.xml | 2 +- .../composeResources/values-th/strings.xml | 2 +- .../composeResources/values-tr-rTR/strings.xml | 2 +- .../composeResources/values-tr/strings.xml | 2 +- .../composeResources/values-uk-rUA/strings.xml | 2 +- .../composeResources/values-uk/strings.xml | 2 +- .../composeResources/values-uz-rUZ/strings.xml | 2 +- .../composeResources/values-vi-rVN/strings.xml | 2 +- .../composeResources/values-zh-rCN/strings.xml | 4 ++-- .../composeResources/values-zh-rHK/strings.xml | 2 +- .../composeResources/values-zh-rSG/strings.xml | 2 +- .../composeResources/values-zh-rTW/strings.xml | 2 +- .../composeResources/values-zh/strings.xml | 2 +- .../commonMain/composeResources/values/strings.xml | 6 +++--- tools/strings-migrate/fix_escapes.py | 13 +++++++++++-- 58 files changed, 92 insertions(+), 78 deletions(-) diff --git a/.claude/hooks/compose_escaping_check.py b/.claude/hooks/compose_escaping_check.py index b5721481ae..4116c90bf1 100755 --- a/.claude/hooks/compose_escaping_check.py +++ b/.claude/hooks/compose_escaping_check.py @@ -66,6 +66,11 @@ def find_violations(root: Path): ) if n: found[path]["raw line break/tab in a value"] += n + # Android's printf escape. Compose substitutes only %N$s / %N$d, so `%%` + # renders as two percent signs ("100%% uptime"). + n = sum(m.group(2).count("%%") for m in STRING_TEXT.finditer(text)) + if n: + found[path]["%%"] += n return found diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt index 1cf4571916..d9c55f6e90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt @@ -185,9 +185,9 @@ fun RelayStatusRow( stringRes(Res.string.errors), modifier = Size15Modifier, tint = - if (successRate < 0.1) { + if (successRate < 10) { MaterialTheme.colorScheme.redColorOnSecondSurface - } else if (successRate < 0.60) { + } else if (successRate < 60) { MaterialTheme.colorScheme.warningColor } else { MaterialTheme.colorScheme.allGoodColor diff --git a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml index eb8f051c0d..45bda2bbd6 100644 --- a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml @@ -3021,7 +3021,7 @@ فشل رفع الصوت: %1$s فشل رفع الرسالة الصوتية NIP-95 غير مدعوم للرسائل الصوتية بعد - %1$d%% وقت التشغيل + %1$d% وقت التشغيل محرك Tor النشط استخدم الإصدار الداخلي أو Orbot لا يمتلك المستخدم عنوان (Lightning Address) لاستقبال sats diff --git a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml index a3394863e6..1e351c6777 100644 --- a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml @@ -2897,7 +2897,7 @@ ভয়েস আপলোড ব্যর্থ হয়েছে: %1$s ভয়েস বার্তা আপলোড করতে ব্যর্থ হয়েছে NIP-95 এখনও ভয়েস বার্তার জন্য সমর্থিত নয় - %1$d%% আপটাইম + %1$d% আপটাইম সক্রিয় Tor ইঞ্জিন অভ্যন্তরীণ সংস্করণ বা Orbot ব্যবহার করুন স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো বিজলি-ঠিকানা দেওয়া নেই diff --git a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml index a59a25da1f..f97f30c142 100644 --- a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -325,7 +325,7 @@ Přidejte alespoň jednu keš. Zveřejnění se nezdařilo. Zkontrolujte připojení k relayům a zkuste to znovu. Zapy na toto se rozdělují mezi: - %1$d%% + %1$d% Value-for-Value Otevření Čtení @@ -3114,7 +3114,7 @@ %1$d relayů odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika - %1$d %% ze všech + %1$d % ze všech Aktivní odběry relayů %1$d filtr zatím není přiřazen @@ -5352,7 +5352,7 @@ Nahrávání hlasu selhalo: %1$s Nepodařilo se nahrát hlasovou zprávu NIP-95 zatím není pro hlasové zprávy podporován - %1$d%% dostupnost + %1$d% dostupnost Aktivní Tor Engine Použijte interní verzi nebo Orbot Uživatel nemá nastavenou LN adresu pro přijímání sats diff --git a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index df7de8f834..a6b7b9fd49 100644 --- a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -313,7 +313,7 @@ Füge mindestens einen Cache hinzu. Veröffentlichen fehlgeschlagen. Prüfe deine Relay-Verbindung und versuche es erneut. Zaps hierauf werden aufgeteilt zwischen: - %1$d%% + %1$d% Value-for-Value Öffnen Lesen @@ -3088,7 +3088,7 @@ %1$d Relays abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose - %1$d %% von allen + %1$d % von allen Aktive Relay-Abonnements %1$d Filter ist noch nicht zugeordnet @@ -5136,7 +5136,7 @@ Sprach-Upload fehlgeschlagen: %1$s Sprachnachricht konnte nicht hochgeladen werden NIP-95 wird für Sprachnachrichten noch nicht unterstützt - %1$d%% Verfügbarkeit + %1$d% Verfügbarkeit Aktiver Tor-Engine Verwende die interne Version oder Orbot Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen diff --git a/commonsUI/src/commonMain/composeResources/values-de/strings.xml b/commonsUI/src/commonMain/composeResources/values-de/strings.xml index a062bd2b0d..a9ecda02cd 100644 --- a/commonsUI/src/commonMain/composeResources/values-de/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de/strings.xml @@ -2988,7 +2988,7 @@ Sprach-Upload fehlgeschlagen: %1$s Sprachnachricht konnte nicht hochgeladen werden NIP-95 wird für Sprachnachrichten noch nicht unterstützt - %1$d%% Verfügbarkeit + %1$d% Verfügbarkeit Aktiver Tor-Engine Verwenden Sie die interne Version oder Orbot Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen diff --git a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml index 16c37e259b..7d8881a6a3 100644 --- a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml @@ -2846,7 +2846,7 @@ Αποτυχία ανεβάσματος φωνητικού: %1$s Αποτυχία ανεβάσματος φωνητικού μηνύματος Το NIP-95 δεν υποστηρίζεται ακόμα για φωνητικά μηνύματα - %1$d%% λειτουργία + %1$d% λειτουργία Ενεργή μηχανή Tor Χρησιμοποιήστε την εσωτερική έκδοση ή το Orbot Ο χρήστης δεν έχει ορίσει διεύθυνση LN για να λαμβάνει sats diff --git a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml index 41f7ad82ec..1f2164b0ee 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml @@ -2901,7 +2901,7 @@ Voĉa alŝuto malsukcesis: %1$s Malsukcesis alŝuti voĉan mesaĝon NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj - %1$d%% funkcia tempo + %1$d% funkcia tempo Aktiva Tor-Motoro Uzu la internan version aŭ Orbot Uzanto ne havas lightning-adreson por ricevi satojn diff --git a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml index 3274fa4e7d..797f4b70f0 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml @@ -2952,7 +2952,7 @@ Voĉa alŝuto malsukcesis: %1$s Malsukcesis alŝuti voĉan mesaĝon NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj - %1$d%% funkcia tempo + %1$d% funkcia tempo Aktiva Tor-Motoro Uzu la internan version aŭ Orbot Uzanto ne havas lightning-adreson por ricevi satojn diff --git a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml index 22de7cb7f9..269e81bc18 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml @@ -2915,7 +2915,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene una configuración de dirección Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml index 72e983759b..ec35d46499 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml @@ -2908,7 +2908,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene configurada una dirección de Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml index 324e61dcb2..1122a9df6a 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml @@ -2908,7 +2908,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene configurada una dirección de Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index b50a02847d..c3ca94cced 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -2952,7 +2952,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene una configuración de dirección Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml index 3077ca6903..ce1d4cdafe 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml @@ -2909,7 +2909,7 @@ آپلود صوتی ناموفق بود: %1$s آپلود پیام صوتی ناموفق بود NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود - %1$d%% زمان فعالیت + %1$d% زمان فعالیت موتور فعال تور استفاده از نسخه درونی یا Orbot این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است diff --git a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml index d45ac6d794..29be72a76e 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml @@ -2952,7 +2952,7 @@ آپلود صوتی ناموفق بود: %1$s آپلود پیام صوتی ناموفق بود NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود - %1$d%% زمان فعالیت + %1$d% زمان فعالیت موتور فعال تور استفاده از نسخه درونی یا Orbot این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است diff --git a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml index f7927b5a97..a4ae7810a9 100644 --- a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml @@ -2877,7 +2877,7 @@ Ääniviesti epäonnistui: %1$s Ääniviesti epäonnistui ladata NIP-95 ei tue ääniviestiä vielä - %1$d%% käytettävyys + %1$d% käytettävyys Aktiivinen Tor-moottori Käytä sisäistä versiota tai Orbotia Käyttäjällä ei ole asetettuna Lightning-osoitetta satsien vastaanottamiseksi diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml index 8feba34b61..27a0712c55 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml @@ -2802,7 +2802,7 @@ Échec de l'envoi de la voix : %1$s Échec de l'envoi du message vocal NIP-95 n'est pas encore supporté pour les messages vocaux - %1$d%% de disponibilité + %1$d% de disponibilité Activer le moteur Tor Utiliser la version interne ou Orbot L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml index 7ffcdad0ac..d1ca232f3a 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml @@ -3092,7 +3092,7 @@ Échec de l'envoi de la voix : %1$s Échec de l'envoi du message vocal NIP-95 n'est pas encore supporté pour les messages vocaux - %1$d%% de disponibilité + %1$d% de disponibilité Activer le moteur Tor Utiliser la version interne ou Orbot L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats diff --git a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml index 183135accd..136defe6e8 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml @@ -2952,7 +2952,7 @@ Échec de l'envoi de la voix : %1$s Échec de l'envoi du message vocal NIP-95 n'est pas encore supporté pour les messages vocaux - %1$d%% de disponibilité + %1$d% de disponibilité Activer le moteur Tor Utiliser la version interne ou Orbot L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index a518b1b8dc..8c4f15602e 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -5147,7 +5147,7 @@ ध्वनि आरोहण असफल : %1$s ध्वनि सन्देश आरोहण असफल निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए - %1$d%% समय निरन्तर उपलब्ध + %1$d% समय निरन्तर उपलब्ध सक्रिय टोर उपकरण आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index c33e43e167..19096e20f9 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -313,7 +313,7 @@ Adjon hozzá legalább egy ládát. Nem sikerült közzétenni. Ellenőrizze a kapcsolatot az átjátszóval, és próbálja újra. Az erre küldött Zapek megoszlanak a következők között: - %1$d%% + %1$d% Értéket az értékért Megnyitás Olvasás @@ -3088,7 +3088,7 @@ %1$d átjátszó előfizetések, szűrők, átjátszók, kérések, kapcsolatok, miért diagnosztika - %1$d%% az összesből + %1$d% az összesből Aktív átjátszó-előfizetések %1$d szűrő még nincs hozzárendelve @@ -5134,7 +5134,7 @@ Nem sikerült feltölteni a hangüzenetet: %1$s Nem sikerült feltölteni a hangüzenetet A NIP-95 még nem támogatja a hangüzeneteket - Üzemidő: %1$d%% + Üzemidő: %1$d% Aktív Tor-motor Használja a beépített verziót vagy az Orbotot A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel diff --git a/commonsUI/src/commonMain/composeResources/values-in/strings.xml b/commonsUI/src/commonMain/composeResources/values-in/strings.xml index 55ec897974..d957d12d1c 100644 --- a/commonsUI/src/commonMain/composeResources/values-in/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in/strings.xml @@ -2915,7 +2915,7 @@ Unggahan suara gagal: %1$s Gagal mengunggah pesan suara NIP-95 belum didukung untuk pesan suara - %1$d%% uptime + %1$d% uptime Mesin Tor Aktif Gunakan versi internal atau Orbot Pengguna tidak memiliki alamat lightning (LN) yang diatur untuk menerima sats diff --git a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml index 46a70f4033..940fd670d6 100644 --- a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml @@ -2841,7 +2841,7 @@ Caricamento vocale fallito: %1$s Impossibile caricare il messaggio vocale NIP-95 non è ancora supportato per i messaggi vocali - %1$d%% di uptime + %1$d% di uptime Motore Tor attivo Usa la versione interna o Orbot L'utente non dispone di un indirizzo Lightning per ricevere sats diff --git a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml index d05cf2e3ba..2fbc006550 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -2861,7 +2861,7 @@ ボイスのアップロードに失敗しました: %1$s ボイスメッセージのアップロードに失敗しました NIP-95 はボイスメッセージにまだ対応していません - %1$d%%稼働率 + %1$d%稼働率 Tor エンジンを有効化 内蔵版または Orbot を使用 satsを受け取るためのLightningアドレスが設定されていません diff --git a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml index e2b24e5fac..7791621a8a 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml @@ -2915,7 +2915,7 @@ ボイスのアップロードに失敗しました: %1$s ボイスメッセージのアップロードに失敗しました NIP-95 はボイスメッセージにまだ対応していません - %1$d%%稼働率 + %1$d%稼働率 Tor エンジンを有効化 内蔵版または Orbot を使用 satsを受け取るためのLightningアドレスが設定されていません diff --git a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml index b5f94f364a..a20e9d6e73 100644 --- a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml @@ -2846,7 +2846,7 @@ 음성 업로드 실패: %1$s 음성 메시지 업로드에 실패했습니다 NIP-95는 아직 음성 메시지를 지원하지 않습니다 - %1$d%% 업타임 + %1$d% 업타임 내장 Tor 엔진 내장 버전 또는 Orbot을 사용하세요 이 사용자는 sats를 받을 Lightning 주소가 설정되어 있지 않습니다 diff --git a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml index 159c676759..24f166016f 100644 --- a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml @@ -2930,7 +2930,7 @@ Balss augšupielāde neizdevās: %1$s Neizdevās augšupielādēt balss ziņojumu NIP-95 balss ziņojumiem vēl netiek atbalstīts - %1$d%% darbalaiks + %1$d% darbalaiks Aktīvs Tor dzinējs Izmantot iekšējo versiju vai Orbot Lietotājs nav iestatījis lightning adresi sats saņemšanai diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml index b47be71e33..f6bda663e7 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml @@ -2952,7 +2952,7 @@ Spraakbericht uploaden mislukt: %1$s Spraakbericht uploaden mislukt NIP-95 wordt nog niet ondersteund voor spraakberichten - %1$d%% uptime + %1$d% uptime Interne Tor-engine gebruiken Gebruik de ingebouwde versie van Orbot Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml index cc49452a22..cbaa5f2203 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml @@ -163,7 +163,7 @@ Weggebeurtenis Zaps hiervoor worden verdeeld over: - %1$d%% + %1$d% Value-for-Value Openen Lezen @@ -2615,7 +2615,7 @@ %1$d relays abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek - %1$d%% van alles + %1$d% van alles Actieve relay-abonnementen %1$d filter is nog niet toegewezen @@ -4567,7 +4567,7 @@ Spraakbericht uploaden mislukt: %1$s Spraakbericht uploaden mislukt NIP-95 wordt nog niet ondersteund voor spraakberichten - %1$d%% uptime + %1$d% uptime Interne Tor-engine gebruiken Gebruik de ingebouwde versie van Orbot Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen diff --git a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml index 062bc8ef1b..2720341499 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml @@ -2952,7 +2952,7 @@ Spraakbericht uploaden mislukt: %1$s Spraakbericht uploaden mislukt NIP-95 wordt nog niet ondersteund voor spraakberichten - %1$d%% uptime + %1$d% uptime Actieve Tor Engine Gebruik de interne versie van Orbot Gebruiker heeft geen Lightning Adress ingesteld om sats te ontvangen diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index a9bdb24080..8d50a907f5 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -325,7 +325,7 @@ Dodaj co najmniej jedną skrzynkę. Nie można opublikować. Sprawdź połączenie z transmiterem i spróbuj ponownie. Zaps do tego są podzielone między: - %1$d%% + %1$d% Wartość-za-wartość Otwórz Odczyt @@ -3144,7 +3144,7 @@ %1$d transmitery subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka - %1$d%% z wszystkich + %1$d% z wszystkich Aktywne subskrypcje transmitera %1$d filtr nie został jeszcze przypisany @@ -5382,7 +5382,7 @@ Nie udało się przesłać głosu: %1$s Nie udało się przesłać wiadomości głosowej NIP-95 nie jest jeszcze dostępny dla wiadomości głosowych - Czas działania %1$d%% + Czas działania %1$d% Aktywny silnik Tor Użyj wersji wewnętrznej lub Orbota Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index 7fa393e1b3..d71a888f5b 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -313,7 +313,7 @@ Adicione pelo menos um cache. Não foi possível publicar. Verifique sua conexão com os relays e tente novamente. Os zaps para isto são divididos entre: - %1$d%% + %1$d% Valor-para-Valor Abrir Leitura @@ -3088,7 +3088,7 @@ %1$d Relés assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico - %1$d%% de todos + %1$d% de todos Assinaturas de relay ativas %1$d filtro ainda não foi atribuído @@ -5134,7 +5134,7 @@ Falha no upload de voz: %1$s Falha ao enviar mensagem de voz O NIP-95 ainda não é suportado para mensagens de voz - %1$d%% de disponibilidade + %1$d% de disponibilidade Motor Tor Ativo Use a versão interna ou o Orbot Usuário não tem um endereço lightning configurado para receber sats diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml index 5225a9ebf9..cc543a7c38 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml @@ -2861,7 +2861,7 @@ Falha no upload de voz: %1$s Falha ao enviar mensagem de voz O NIP-95 ainda não é suportado para mensagens de voz - %1$d%% de disponibilidade + %1$d% de disponibilidade Motor Tor Ativo Use a versão interna ou o Orbot Usuário não tem um endereço lightning configurado para receber sats diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml index 85e7e02487..940d0affed 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml @@ -2948,7 +2948,7 @@ Ошибка загрузки голосового сообщения: %1$s Неудалось загрузить голосовое сообщение NIP-95 пока не поддерживается для голосовых сообщений - %1$d%% времени работы + %1$d% времени работы Встроенный движок Tor Использовать встроенную версию или Orbot Пользователь не установил Lightning адрес для получения чаевых diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml index a9cb955bbe..1eb0aa80d8 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml @@ -2930,7 +2930,7 @@ Ошибка загрузки голосового сообщения: %1$s Не удалось загрузить голосовое сообщение NIP-95 пока не поддерживается для голосовых сообщений - %1$d%% времени работы + %1$d% времени работы Встроенный движок Tor Использовать встроенную версию или Orbot У пользователя не настроен Lightning-адрес для получения sats diff --git a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml index be13796940..7c8d0ca239 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml @@ -3026,7 +3026,7 @@ Ошибка загрузки голосового сообщения: %1$s Не удалось загрузить голосовое сообщение NIP-95 пока не поддерживается для голосовых сообщений - %1$d%% времени работы + %1$d% времени работы Встроенный движок Tor Использовать встроенную версию или Orbot Пользователь не установил Lightning адрес для получения чаевых diff --git a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml index 9f7b4ef715..12b230a326 100644 --- a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml @@ -175,7 +175,7 @@ Dogodek na cesti Zapi bojo razdeljeni med: - %1$d%% + %1$d% Vrednost za vrednost Odprto Branje @@ -2800,7 +2800,7 @@ %1$d relejev naročnine, filtri, releji, zahteve, povezave, zakaj, diagnostika - %1$d%% od vseh + %1$d% od vseh Aktivne rele naročnine %1$d filter še ni dodeljen @@ -4940,7 +4940,7 @@ Neuspešno nalaganje zvočnega posnetka: %1$s Neuspešno nalaganje zvočnega sporočila NIP-95 še ne podpira zvočnih posnetkov - %1$d%% časa delovanja + %1$d% časa delovanja Aktivni Tor stroj Uporabi interno različico ali Orbot Uporabnik nima nastavljenega "lightning" naslova za sprejem satoshi-jev diff --git a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml index c7b1d4b59c..2d1f5bdc6b 100644 --- a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml @@ -2907,7 +2907,7 @@ Otpremanje glasa nije uspelo: %1$s Otpremanje glasovne poruke nije uspelo NIP-95 još nije podržan za glasovne poruke - %1$d%% dostupnost + %1$d% dostupnost Aktivan Tor motor Koristite internu verziju ili Orbot Korisnik nema podešenu Lightning adresu za primanje sats-a diff --git a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index 05544e3d76..4a2cf970c1 100644 --- a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -313,7 +313,7 @@ Lägg till minst en cache. Kunde inte publicera. Kontrollera reläanslutningen och försök igen. Zaps till detta fördelas mellan: - %1$d%% + %1$d% Värde -för-värde Öppna Läs @@ -3088,7 +3088,7 @@ %1$d reläer prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik - %1$d %% av alla + %1$d % av alla Aktiva reläprenumerationer %1$d filter är inte kopplat ännu @@ -5134,7 +5134,7 @@ Uppladdning av röst misslyckades: %1$s Misslyckades med att ladda upp röstmeddelandet NIP-95 stöds ännu inte för röstmeddelanden - %1$d%% drifttid + %1$d% drifttid Aktiv Tor Engine Använd den interna versionen eller Orbot Användaren har inte en Lightningadressinställning för att ta emot sats diff --git a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml index 286d845f7c..8732ae50c3 100644 --- a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml @@ -2882,7 +2882,7 @@ Upakiaji wa sauti umeshindwa: %1$s Imeshindwa kupakia ujumbe wa sauti NIP-95 haijatarajiwa kwa ujumbe wa sauti bado - %1$d%% wakati wa uendeshaji + %1$d% wakati wa uendeshaji Injini ya Tor Inayofanya Kazi Tumia toleo la ndani au Orbot Mtumiaji hana anwani ya umeme iliyoandaliwa kupokea sati diff --git a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml index 1118006456..1ff151032d 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml @@ -2870,7 +2870,7 @@ குரல் பதிவேற்றம் தோல்வியடைந்தது: %1$s குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது குரல் செய்திகளுக்கு NIP-95 இன்னும் ஆதரிக்கப்படவில்லை - %1$d%% இயக்க நேரம் + %1$d% இயக்க நேரம் செயலில் Tor எஞ்சின் உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும் பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை diff --git a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml index 0c9046b656..a6fa3f2130 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml @@ -2952,7 +2952,7 @@ குரல் பதிவேற்றம் தோல்வியடைந்தது: %1$s குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது குரல் செய்திகளுக்கு NIP-95 இன்னும் ஆதரிக்கப்படவில்லை - %1$d%% இயக்க நேரம் + %1$d% இயக்க நேரம் செயலில் Tor எஞ்சின் உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும் பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை diff --git a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml index cf2b33f695..6d86c4be9d 100644 --- a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml @@ -2710,7 +2710,7 @@ การอัปโหลดเสียงล้มเหลว: %1$s ไม่สามารถอัปโหลดข้อความเสียงได้ NIP-95 ยังไม่รองรับข้อความเสียง - เวลาทำงาน %1$d%% + เวลาทำงาน %1$d% เครื่องยนต์ Tor ที่ใช้งานอยู่ ใช้เวอร์ชันภายในหรือ Orbot ผู้ใช้นี้ไม่ได้ตั้งค่า lightning address เพื่อรับ sats diff --git a/commonsUI/src/commonMain/composeResources/values-th/strings.xml b/commonsUI/src/commonMain/composeResources/values-th/strings.xml index 869f2c34ae..cdb57bb5be 100644 --- a/commonsUI/src/commonMain/composeResources/values-th/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th/strings.xml @@ -2915,7 +2915,7 @@ การอัปโหลดเสียงล้มเหลว: %1$s ไม่สามารถอัปโหลดข้อความเสียงได้ NIP-95 ยังไม่รองรับข้อความเสียง - เวลาทำงาน %1$d%% + เวลาทำงาน %1$d% เครื่องยนต์ Tor ที่ใช้งานอยู่ ใช้เวอร์ชันภายในหรือ Orbot ผู้ใช้นี้ไม่ได้ตั้งค่า lightning address เพื่อรับ sats diff --git a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml index 885e0cedad..c4206f01e1 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml @@ -2877,7 +2877,7 @@ Ses yükleme başarısız: %1$s Sesli mesaj yüklenemedi NIP-95 henüz sesli mesajlar için desteklenmiyor - %1$d%% çalışma süresi + %1$d% çalışma süresi Aktif Tor Motoru Dahili sürümü veya Orbot'u kullanın Kullanıcının sats alabileceği bir lightning adresi kurulu değil diff --git a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml index 234cc91ca6..a54077a881 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml @@ -2952,7 +2952,7 @@ Ses yükleme başarısız: %1$s Sesli mesaj yüklenemedi NIP-95 henüz sesli mesajlar için desteklenmiyor - %1$d%% çalışma süresi + %1$d% çalışma süresi Aktif Tor Motoru Dahili sürümü veya Orbot'u kullanın Kullanıcının sats alabileceği bir lightning adresi kurulu değil diff --git a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml index cdb6fd0b15..a15f4f5fdd 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml @@ -2935,7 +2935,7 @@ Помилка завантаження голосу: %1$s Не вдалося завантажити голосове повідомлення NIP-95 ще не підтримується для голосових повідомлень - %1$d%% доступності + %1$d% доступності Активний рушій Tor Використовувати вбудовану версію або Orbot Користувач не встановив Lightning адресу для отримання чайових diff --git a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml index 7910decae3..8e96de5dd5 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml @@ -3026,7 +3026,7 @@ Помилка завантаження голосу: %1$s Не вдалося завантажити голосове повідомлення NIP-95 ще не підтримується для голосових повідомлень - %1$d%% доступності + %1$d% доступності Активний рушій Tor Використовувати вбудовану версію або Orbot Користувач не встановив Lightning адресу для отримання чайових diff --git a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml index 349da89d24..129ee57d59 100644 --- a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -2886,7 +2886,7 @@ Ovoz yuklash muvaffaqiyatsiz tugadi: %1$s Ovozli xabarni yuklash muvaffaqiyatsiz tugadi NIP-95 ovozli xabarlar uchun hali qo'llab-quvvatlanmaydi - %1$d%% ishga tayyor vaqt + %1$d% ishga tayyor vaqt Faol Tor Mexanizmi Ichki versiya yoki Orbot'dan foydalaning Foydalanuvchi sats qabul qilish uchun lightning manzilini o'rnatmagan diff --git a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml index 41d1564cbe..cc30d18764 100644 --- a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml @@ -2832,7 +2832,7 @@ Tải lên giọng nói thất bại: %1$s Không thể tải lên tin nhắn thoại NIP-95 chưa được hỗ trợ cho tin nhắn thoại - %1$d%% thời gian hoạt động + %1$d% thời gian hoạt động Bật công cụ Tor Sử dụng phiên bản nội bộ hoặc Orbot Người dùng chưa thiết lập địa chỉ Lightning để nhận sats diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml index 068f70ecd9..dc09927a64 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -126,7 +126,7 @@ 道路事件 对此的Zaps 在以下对象间分割: - %1$d%% + %1$d% 值对值 打开 读取 @@ -3845,7 +3845,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未设置闪电地址以接收聪 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml index 09ac836145..0812f168a7 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml @@ -2894,7 +2894,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未設置閃電地址以接收聰 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml index d3164d768c..f835004df2 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml @@ -2894,7 +2894,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未设置闪电地址以接收聪 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml index cdc0f88ed8..2651ec3289 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -2866,7 +2866,7 @@ 語音上傳失敗:%1$s 語音訊息上傳失敗 NIP-95 尚不支援語音訊息 - %1$d%% 正常運行時間 + %1$d% 正常運行時間 啟用 Tor 引擎 使用內建版本或 Orbot 用户尚未設置閃電地址以接收聰 diff --git a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml index 76a3e30f4c..6d7ae350e0 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml @@ -2951,7 +2951,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未设置闪电地址以接收聪 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index cb47a915a8..1c6041261e 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -326,7 +326,7 @@ Add at least one cache. Could not publish. Check your relay connection and try again. Zaps to this are split between: - %1$d%% + %1$d% Value-for-Value Open Read @@ -3156,7 +3156,7 @@ %1$d relays subscriptions filters relays requests reqs connections why diagnostics - %1$d%% of all + %1$d% of all Active Relay Subscriptions %1$d filter is not attributed yet @@ -5254,7 +5254,7 @@ Voice upload failed: %1$s Failed to upload voice message NIP-95 is not supported for voice messages yet - %1$d%% uptime + %1$d% uptime Active Tor Engine Use the internal version or Orbot User does not have a lightning address set up to receive sats diff --git a/tools/strings-migrate/fix_escapes.py b/tools/strings-migrate/fix_escapes.py index 65c7a7c566..7cfef30b83 100755 --- a/tools/strings-migrate/fix_escapes.py +++ b/tools/strings-migrate/fix_escapes.py @@ -78,8 +78,17 @@ def fix_text(text: str, unwrap_quotes: bool = True, source: str = None) -> str: """ if unwrap_quotes and len(text) >= 2 and text.startswith('"') and text.endswith('"'): # Quoted: aapt kept this whitespace exactly, so Compose keeps it too. - return ANDROID_ONLY.sub(r"\1", text[1:-1]) - return normalize_whitespace(ANDROID_ONLY.sub(r"\1", text), source) + return unescape_percent(ANDROID_ONLY.sub(r"\1", text[1:-1])) + return unescape_percent(normalize_whitespace(ANDROID_ONLY.sub(r"\1", text), source)) + + +def unescape_percent(text: str) -> str: + """`%%` is a printf escape Compose never resolves: `%1$d%%` renders `100%%`. + + Compose substitutes only `%N$s`/`%N$d` and leaves every other `%` alone, so a + bare `%` is already literal. Idempotent. + """ + return text.replace("%%", "%") def strip_android_only_attrs(src: str) -> tuple: From 269aa1d5741a25aa9102ac5789754cf8291d79b1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:28:47 -0400 Subject: [PATCH 02/37] fix(chat): don't offer 'Add author to a list' on your own messages The follow/unfollow actions were already hidden for the logged-in user but the list action sat outside that guard, so your own chat bubbles offered to file you into a follow list. The chat action sheet also seeded isLoggedUser=false, flashing the author actions for a frame on your own message. Co-Authored-By: Claude Opus 5.5 --- .../ui/note/elements/NoteActionSections.kt | 15 ++++++++------- .../loggedIn/chats/feed/ChatMessageActionSheet.kt | 3 ++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index 958acad531..7d6719b11b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -159,14 +159,15 @@ fun noteActionSections( }, ) } - } - add( - NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) { - note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) } - handlers.onDismiss() - }, - ) + // Your own posts: there is no "author" to follow or file into a list. + add( + NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) { + note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) } + handlers.onDismiss() + }, + ) + } } // When the rendered note was translated, Copy Text opens a chooser (Copy diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 72b483ee4e..9aeffd628b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -253,7 +253,8 @@ fun ChatMessageActionSheet( isPrivateBookmarkNote = false, isPublicBookmarkNote = false, isPinnedNote = false, - isLoggedUser = false, + // Seeded from the note so the first frame doesn't flash author actions on your own message. + isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = false, showSensitiveContent = null, ), From 91f4b31120cf7986a1c629dee2dc40a469d86f8b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:30:36 -0400 Subject: [PATCH 03/37] fix(chat): say what a deletion actually does for encrypted messages The delete confirmation always said Amethyst would ask 'the relays you are connected to' to delete the note. For a Marmot group message the request goes to the group members; for a NIP-17 DM it is a gift-wrapped request to the conversation. Pick the body by transport. The long-press quick-action Delete also called the public delete for every note, so a NIP-17 DM deleted from there published a public NIP-09 e-tagging the rumor id. All three dialogs now go through AccountViewModel.deleteOwn, which takes the private path for any private rumor. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/ui/note/NoteQuickActionMenu.kt | 23 ++++++++++++++++--- .../amethyst/ui/note/elements/DropDownMenu.kt | 14 +++-------- .../ui/screen/loggedIn/AccountViewModel.kt | 9 ++++++++ .../chats/feed/ChatMessageActionSheet.kt | 14 +++-------- .../composeResources/values/strings.xml | 2 ++ 5 files changed, 37 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 27c3f60fd7..003b8b5f6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.commons.resources.quick_action_follow import com.vitorpamplona.amethyst.commons.resources.quick_action_mute_thread import com.vitorpamplona.amethyst.commons.resources.quick_action_report import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body +import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body_group +import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body_private import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title import com.vitorpamplona.amethyst.commons.resources.quick_action_share import com.vitorpamplona.amethyst.commons.resources.quick_action_share_browser_link @@ -579,6 +581,21 @@ fun NoteQuickActionItem( } } +/** + * What a deletion actually does for [note]: a public NIP-09 to relays, a request sent + * inside a Marmot group, or a gift-wrapped request to a NIP-17 conversation. The public + * wording ("deleted from the relays you are connected to") is wrong for the last two. + */ +fun deletionRequestBody( + note: Note, + accountViewModel: AccountViewModel, +): StringResource = + when { + accountViewModel.account.marmot.marmotGroupOf(note) != null -> Res.string.quick_action_request_deletion_alert_body_group + note.isPrivateRumor() -> Res.string.quick_action_request_deletion_alert_body_private + else -> Res.string.quick_action_request_deletion_alert_body + } + @Composable fun DeleteAlertDialog( note: Note, @@ -587,15 +604,15 @@ fun DeleteAlertDialog( ) { QuickActionAlertDialog( title = stringRes(Res.string.quick_action_request_deletion_alert_title), - textContent = stringRes(Res.string.quick_action_request_deletion_alert_body), + textContent = stringRes(deletionRequestBody(note, accountViewModel)), buttonIcon = MaterialSymbols.Delete, buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), onClickDoOnce = { - accountViewModel.delete(note) + accountViewModel.deleteOwn(note) onDismiss() }, onClickDontShowAgain = { - accountViewModel.delete(note) + accountViewModel.deleteOwn(note) accountViewModel.account.settings.setHideDeleteRequestDialog() onDismiss() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index fd09217ed1..313da6e77e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.note_actions_dialog_title import com.vitorpamplona.amethyst.commons.resources.quick_action_delete_dialog_btn -import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable @@ -47,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog +import com.vitorpamplona.amethyst.ui.note.deletionRequestBody import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog @@ -163,20 +163,12 @@ fun NoteDropDownMenu( ) } - // Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped - // deletion — a public NIP-09 would e-tag the rumor id onto public relays. - val performDelete = { - if (note.isPrivateRumor()) { - accountViewModel.deletePrivately(note) - } else { - accountViewModel.delete(note) - } - } + val performDelete = { accountViewModel.deleteOwn(note) } if (deleteConfirmationShowing) { QuickActionAlertDialog( title = stringRes(Res.string.quick_action_request_deletion_alert_title), - textContent = stringRes(Res.string.quick_action_request_deletion_alert_body), + textContent = stringRes(deletionRequestBody(note, accountViewModel)), buttonIcon = MaterialSymbols.Delete, buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), onClickDoOnce = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 80bfb805b6..19517ee43c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -596,6 +596,15 @@ class AccountViewModel( reactToOrDelete(note, reaction) } + /** + * Retracts one of your own notes the way its transport requires. Private rumors (NIP-17 DMs, + * Marmot group messages) take a private deletion; a public NIP-09 would e-tag the rumor id + * onto public relays. + */ + fun deleteOwn(note: Note) { + if (note.isPrivateRumor()) deletePrivately(note) else delete(note) + } + /** * Retracts the user's own private rumor (e.g. a NIP-17 DM message) with a * gift-wrapped NIP-09 deletion to the same participants. A public deletion diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 9aeffd628b..bf0bbd0eb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -72,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error import com.vitorpamplona.amethyst.commons.resources.more_options import com.vitorpamplona.amethyst.commons.resources.no_wallet_found import com.vitorpamplona.amethyst.commons.resources.quick_action_delete_dialog_btn -import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title import com.vitorpamplona.amethyst.commons.resources.relay_group_pin_message import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message @@ -94,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog import com.vitorpamplona.amethyst.ui.note.RenderReaction import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid +import com.vitorpamplona.amethyst.ui.note.deletionRequestBody import com.vitorpamplona.amethyst.ui.note.elements.AddHashtagLabelDialog import com.vitorpamplona.amethyst.ui.note.elements.ConcordBanConfirmationDialog import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams @@ -146,20 +146,12 @@ fun ChatMessageActionSheet( var concordBanConfirming by remember { mutableStateOf(false) } var showDeliveryDialog by remember { mutableStateOf(false) } - // Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped - // deletion — a public NIP-09 would e-tag the rumor id onto public relays. - val performDelete = { - if (note.isPrivateRumor()) { - accountViewModel.deletePrivately(note) - } else { - accountViewModel.delete(note) - } - } + val performDelete = { accountViewModel.deleteOwn(note) } if (deleteConfirmationShowing) { QuickActionAlertDialog( title = stringRes(Res.string.quick_action_request_deletion_alert_title), - textContent = stringRes(Res.string.quick_action_request_deletion_alert_body), + textContent = stringRes(deletionRequestBody(note, accountViewModel)), buttonIcon = MaterialSymbols.Delete, buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), onClickDoOnce = { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1c6041261e..50aec281b5 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -721,6 +721,8 @@ Remove this media from your Gallery. Request Deletion Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored. + Amethyst will ask everyone in this encrypted group to delete this message. Their apps should remove it, but there is no guarantee that every member will delete their copy. + Amethyst will send an encrypted deletion request to the people in this conversation. Their apps should remove the message, but there is no guarantee that every copy will be deleted. Block Delete Block From 1483b74cfefb43b0411fc5fe2a556a26117738f1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:31:25 -0400 Subject: [PATCH 04/37] fix(marmot): refresh the admin roster right after granting or revoking admin grant/revokeMarmotGroupAdmin committed the new admin_pubkeys but, unlike every other group-context action here, never synced the chatroom, so Group Info kept showing the old admins (and the old admin actions) until a restart. Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/model/AccountMarmotActions.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 15df5f3552..e09f4b31c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -1018,6 +1018,9 @@ class AccountMarmotActions( if (view.adminPubkeys.contains(targetPubKey)) return manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList()) + // The commit is canonical once published; without this the roster and the + // admin badges keep the pre-commit admin list until our own echo or a restart. + manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId)) } /** @@ -1042,5 +1045,8 @@ class AccountMarmotActions( } manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList()) + // The commit is canonical once published; without this the roster and the + // admin badges keep the pre-commit admin list until our own echo or a restart. + manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId)) } } From d24dc69a29d4dae1b8fcad0b7b9568508b727f72 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:34:29 -0400 Subject: [PATCH 05/37] fix(marmot): show a new group's name and members as soon as it is created createMarmotGroup and the create/edit screen's metadata commit (which calls MarmotManager.setGroupProfile directly, bypassing the syncing action in AccountMarmotActions) never copied the committed state into the chatroom. A freshly created group showed '0 members' and no name until our own commit echoed back or the app restarted. Sync and notify the list after both. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 12 ++++++++++++ .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 3 +++ 2 files changed, 15 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index e09f4b31c8..dd893b55af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -769,6 +769,18 @@ class AccountMarmotActions( // Creator owns the group — mark it as "known" immediately so it // doesn't appear under "New Requests" before the first message. account.marmotGroupList.markAsKnown(nostrGroupId) + syncAndNotify(nostrGroupId) + } + + /** + * Copy the group's current MLS state (name, admins, members, relays) into its + * chatroom and tell the list to re-render it. For actions that commit outside the + * paths here which already sync. + */ + fun syncAndNotify(nostrGroupId: HexKey) { + val manager = account.marmotManager ?: return + manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId)) + account.marmotGroupList.notifyGroupChanged(nostrGroupId) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 19517ee43c..0439256b55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2715,6 +2715,9 @@ class AccountViewModel( relays.toList(), ) } + // The commits are canonical once published. Surface them now: a freshly created + // group otherwise sat at "0 members" with no name until our own echo or a restart. + account.marmot.syncAndNotify(nostrGroupId) } override fun onCleared() { From cf930b05648c68987182574b61c5f67a794b8390 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:38:14 -0400 Subject: [PATCH 06/37] fix(marmot): keep a group you created out of New Requests after a restart Creating a group marked it Known with an in-memory flag only, so after a restart a creator who had not posted yet found their own group under New Requests (no followed admin, since the only admin is themselves). syncMetadataTo, which runs on restore, now marks the group ours when we hold leaf 0: the creator always does, and RFC 9420 only places a joiner there after the creator has left. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/commons/marmot/MarmotManager.kt | 6 +++++ .../commons/marmot/MarmotDisbandTest.kt | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 9a30894f69..2b59649dcc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -2676,6 +2676,12 @@ class MarmotManager( // events the UI never sees directly — a disband request resolving, a // removal being realized. chatroom.outboundGate.value = publishGate.outboundGateNow(nostrGroupId) + // A group we created is ours: keep it out of "New Requests" across restarts. + // `markAsKnown` at creation is in-memory only, and a creator who has not posted + // yet has nothing else that says so. The creator holds leaf 0 (RFC 9420 adds a + // joiner at the leftmost BLANK leaf, and leaf 0 is never blank while its creator + // is in the group), so an invitee only lands there after the creator has left. + if (groupManager.getGroup(nostrGroupId)?.leafIndex == 0) chatroom.ownerSentMessage = true val previousCount = chatroom.members.value.size val members = memberPubkeys(nostrGroupId) chatroom.members.value = members diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt index 547ec25c2b..20532128d5 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt @@ -260,6 +260,28 @@ class MarmotDisbandTest { assertEquals(LocalOutboundGate.DISBANDING, chatroom.outboundGate.value) } + @Test + fun `a group we created stays Known after a restart, an invitation does not`() = + runBlocking { + // "Known" at creation was an in-memory flag, so after a restart a creator who + // had not posted yet found their own group under New Requests. The sync that + // runs on restore has to reach the same answer from the MLS state alone. + val alice = Fixture() + val bob = Fixture() + alice.createCurrentProfile() + val kp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList()) + bob.manager.ingest(welcome!!.giftWrapEvent) + + val alicesRoom = MarmotGroupChatroom(nostrGroupId) + val bobsRoom = MarmotGroupChatroom(nostrGroupId) + alice.manager.syncMetadataTo(nostrGroupId, alicesRoom) + bob.manager.syncMetadataTo(nostrGroupId, bobsRoom) + + assertTrue(alicesRoom.isKnown(emptySet()), "the creator's own group") + assertTrue(!bobsRoom.isKnown(emptySet()), "an invitation from someone we don't follow") + } + @Test fun `rejoining a group we left clears the departure gate`() = runBlocking { From 3e313f7004f15797d5e311a300006fa0b2e4f137 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:40:07 -0400 Subject: [PATCH 07/37] fix(login): don't steal focus from an ncryptsec while it is being typed The password field appears as soon as the key starts with 'ncryptsec1', and it grabbed focus 300ms later. Typing an ncryptsec by hand therefore lost focus after its tenth character and the rest landed in the password. Move focus only once the field holds a complete, checksummed ncryptsec, which still makes a paste jump straight to the password. Co-Authored-By: Claude Opus 5.5 --- .../ui/screen/loggedOff/login/LoginScreen.kt | 11 +++++++--- .../screen/loggedOff/login/LoginViewModel.kt | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index b35647c0e2..c860a5841b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -274,9 +274,14 @@ private fun PasswordField(loginViewModel: LoginViewModel) { onGo = loginViewModel::login, ) - LaunchedEffect(Unit) { - delay(300) - passwordFocusRequester.requestFocus() + // Only once the key is complete: a pasted ncryptsec jumps straight to the password, + // one being typed keeps its focus until the last character. + val keyComplete = loginViewModel.isCompleteNcryptsec + LaunchedEffect(keyComplete) { + if (keyComplete) { + delay(300) + passwordFocusRequester.requestFocus() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index fd91a9fdec..81a2326351 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.resources.sign_request_rejected_descri import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @Stable class LoginViewModel : ViewModel() { @@ -62,6 +63,20 @@ class LoginViewModel : ViewModel() { Bech32Transcription.normalize(key.text).startsWith("ncryptsec1") } + /** + * The key field holds a whole, checksummed ncryptsec. [needsPassword] turns true at the + * first "ncryptsec1", which is what shows the password field; moving focus there has to + * wait for this, or a key typed by hand loses focus after its tenth character. + */ + val isCompleteNcryptsec by derivedStateOf { + needsPassword && + try { + Bech32Transcription.normalize(key.text).bechToBytes("ncryptsec").size == NCRYPTSEC_PAYLOAD_SIZE + } catch (e: Exception) { + false + } + } + var isFirstLogin by mutableStateOf(false) fun init(accountSessionManager: AccountSessionManager) { @@ -192,4 +207,9 @@ class LoginViewModel : ViewModel() { } } } + + companion object { + // version + log_n + salt(16) + nonce(24) + key security + ciphertext(48), per NIP-49. + private const val NCRYPTSEC_PAYLOAD_SIZE = 91 + } } From eb338d40c27de6d10459b8d96a681f9a10adef90 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:40:08 -0400 Subject: [PATCH 08/37] fix(backup): clear typed backup passwords when the app goes to the background The encrypted-backup card kept a typed but unused password across ON_STOP on purpose, so a user could switch to a password manager app to fetch it. That also left it in memory behind other apps and, with 'show password' on, in the recents thumbnail. Clear the password, its confirmation and the show toggle on ON_STOP, like the result already was. Trade-off: copying a password out of a separate password manager app and coming back now finds the fields empty. Autofill fills in place without stopping the activity and still works. The password is read before the key-access gate, because a device-credential prompt is an activity that stops this one and would otherwise encrypt with the just-cleared field. Co-Authored-By: Claude Opus 5.5 --- .../loggedIn/keyBackup/AccountBackupScreen.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index 5468d27777..763670e9df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -401,11 +401,16 @@ private fun EncryptedKeyCard( var encrypted by remember { mutableStateOf(null) } var showQr by remember { mutableStateOf(false) } - // Drop the result while in the background. A password not yet used is kept so switching to - // a password manager to fetch it doesn't wipe the fields; a used one is cleared on success. + // Drop the result and the typed password while in the background, so neither sits in memory + // (or on the recents thumbnail, with the password shown) behind another app. The cost: leaving + // to copy a password out of a password manager app clears what was typed; autofill, which + // fills in place without stopping this activity, is unaffected. LifecycleEventEffect(Lifecycle.Event.ON_STOP) { encrypted = null showQr = false + password = "" + repeated = "" + showPassword = false } // A typo in the password makes the backup permanently useless, so it must be typed twice. @@ -415,9 +420,11 @@ private fun EncryptedKeyCard( fun encrypt() { if (!canEncrypt) return + // Read before the gate: a device-credential prompt is its own activity, so it stops this + // one and the ON_STOP above clears the fields before the unlock callback runs. + val currentPassword = password gate.withAccess { val privKey = accountViewModel.account.settings.keyPair.privKey ?: return@withAccess - val currentPassword = password working = true // NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread. scope.launch { From 50eb35755d607b69f4d241b82f28e564d742875f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:41:35 -0400 Subject: [PATCH 09/37] fix(uploads): show why an upload failed, not just 'Error' UploadingState.Error carries the reason (server refused the file type, payment required, ...) but both progress badges rendered a fixed 'Error'. The attachment thumbnail badge only has room for a word, so tapping it now opens the reason with Close / Remove (a failed attachment previously had no delete button at all). The full-width indicator used by the post, voice-reply and share screens prints the reason under the badge. MultiOrchestrator's item list is now snapshot state: it was a plain var, so removing an item (this Remove, and the existing X on a ready item) changed the upload but left the thumbnail on screen. Co-Authored-By: Claude Opus 5.5 --- .../service/uploads/MultiOrchestrator.kt | 7 ++- .../ui/actions/uploads/ShowImageUploadItem.kt | 55 ++++++++++++++++++- .../uploads/UploadProgressIndicator.kt | 33 +++++++---- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index fa73ff1268..4abc711481 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -37,7 +40,9 @@ import kotlinx.coroutines.launch class MultiOrchestrator( uris: List, ) { - private var list: List = uris.map { SelectedMediaProcessing(it) } + // Snapshot state so a removal recomposes the gallery that draws it; as a plain var the + // Remove on a failed upload dropped the item from the upload but left it on screen. + private var list: List by mutableStateOf(uris.map { SelectedMediaProcessing(it) }) @Stable class Result( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt index 56e889b505..9064d7ecf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt @@ -28,6 +28,7 @@ import android.os.Build import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,11 +39,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -64,6 +67,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.close +import com.vitorpamplona.amethyst.commons.resources.remove +import com.vitorpamplona.amethyst.commons.resources.upload_error_title import com.vitorpamplona.amethyst.commons.resources.uploading_state_compressing import com.vitorpamplona.amethyst.commons.resources.uploading_state_downloading import com.vitorpamplona.amethyst.commons.resources.uploading_state_error @@ -216,10 +222,55 @@ fun OrchestratorOverlay( val progress by orchestrator.progress.collectAsState() val progressState by orchestrator.progressState.collectAsState() - if (progressState is UploadingState.Ready) { + val state = progressState + if (state is UploadingState.Ready) { DeleteButton(onDelete) + } else if (state is UploadingState.Error) { + UploadErrorBadge(progress, state, onDelete) } else { - UploadingState(progress, progressState) + UploadingState(progress, state) + } +} + +/** + * A failed upload: the badge alone has room for one word, so tapping it opens the reason + * (server refused the file type, payment required, no connection, ...). Without it the + * user had "Error" and nothing to act on, and no way to drop the failed attachment. + */ +@Composable +private fun UploadErrorBadge( + progress: Double, + state: UploadingState.Error, + onDelete: () -> Unit, +) { + var showDetails by remember { mutableStateOf(false) } + + Box( + modifier = + Modifier + .fillMaxSize() + .clickable { showDetails = true }, + ) { + UploadingState(progress, state) + } + + if (showDetails) { + AlertDialog( + onDismissRequest = { showDetails = false }, + title = { Text(stringRes(Res.string.upload_error_title)) }, + text = { Text(stringRes(state.errorResource, *state.params)) }, + confirmButton = { + TextButton(onClick = { showDetails = false }) { Text(stringRes(Res.string.close)) } + }, + dismissButton = { + TextButton( + onClick = { + showDetails = false + onDelete() + }, + ) { Text(stringRes(Res.string.remove)) } + }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt index 01dd19bfff..279d636de8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -51,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState @Composable fun UploadProgressIndicator( @@ -60,12 +62,12 @@ fun UploadProgressIndicator( val progressValue = orchestrator.progress.collectAsState().value val progressStatusValue = orchestrator.progressState.collectAsState().value - Box( + Column( modifier = modifier .fillMaxWidth() .padding(vertical = 24.dp), - contentAlignment = Alignment.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { Box( modifier = Modifier.size(55.dp), @@ -88,14 +90,14 @@ fun UploadProgressIndicator( val txt = when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(Res.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(Res.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(Res.string.uploading_state_error) + is UploadingState.Ready -> stringRes(Res.string.uploading_state_ready) + is UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing) + is UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading) + is UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing) + is UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading) + is UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing) + is UploadingState.Finished -> stringRes(Res.string.uploading_state_finished) + is UploadingState.Error -> stringRes(Res.string.uploading_state_error) } Text( @@ -105,5 +107,16 @@ fun UploadProgressIndicator( textAlign = TextAlign.Center, ) } + + // The badge fits one word; the reason is what the user can act on. + if (progressStatusValue is UploadingState.Error) { + Text( + stringRes(progressStatusValue.errorResource, *progressStatusValue.params), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp), + ) + } } } From e7886b744dd97268040f7f805f2c2d3fd70c972f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:43:35 -0400 Subject: [PATCH 10/37] fix(marmot): no 'Event is loading or can't be found' rows in group chats showOwnMessageLocally indexed our own outgoing inner events with a bare justConsume, which returns false for kinds LocalCache doesn't dispatch (the push-token lists we answer peers with, 447-449, plus edits and stream starts). The note then never got its event, and MarmotGroupList let any eventless note through before checking kinds, so each of those rendered as a placeholder row in the conversation. White Noise showed nothing there. Index own messages with indexMarmotInnerEvent, like received ones, and refuse eventless notes in the feed. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 11 +++++------ .../commons/model/marmotGroups/MarmotGroupList.kt | 4 +++- .../marmotGroups/MarmotGroupFeedVisibilityTest.kt | 8 ++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index dd893b55af..116e36b8b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -128,12 +128,11 @@ class AccountMarmotActions( nostrGroupId: HexKey, innerEvent: Event, ) { - // wasVerified=true: MIP-03 inner events are unsigned rumors, so - // Schnorr verification would reject every one. This one we built - // ourselves, which is as authenticated as it gets. - val isNew = account.cache.justConsume(innerEvent, null, true) - val innerNote = account.cache.getOrCreateNote(innerEvent.id) - if (isNew) innerNote.event = innerEvent + // The same indexing as a received message. A bare justConsume returns false for a kind + // LocalCache does not dispatch (push-token lists 447-449, edits, stream starts), which + // left our own copy an EVENTLESS note in the conversation: an "Event is loading or can't + // be found" row for every push-token answer we sent. + val innerNote = indexMarmotInnerEvent(innerEvent).note account.marmotGroupList.addMessage(nostrGroupId, innerNote) // Sending a message moves the group out of "New Requests" into // "Known" — do this eagerly before the relay round-trip so the UI diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 63be18ff2b..2d767a6aa8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -146,7 +146,9 @@ class MarmotGroupList( * authorship rule below. */ private fun isDisplayableFeedMessage(msg: Note): Boolean { - val kind = msg.event?.kind ?: return true + // Every path here indexes the decrypted inner event onto its note first, so a note + // without one can't be classified and would only render as a "can't be found" row. + val kind = msg.event?.kind ?: return false if (kind == MARMOT_INNER_KIND_SYSTEM_ROW) return isOwnDerivedSystemRow(msg) return kind !in NON_CHAT_INNER_KINDS } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt index 1caf3dacd4..57363d7fbc 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt @@ -105,4 +105,12 @@ class MarmotGroupFeedVisibilityTest { assertTrue(list.getOrCreateGroup(groupId).messages.size == 0, "a payload cannot vouch for itself") assertFalse(list.groupIdForNote(forged.idHex) == groupId) } + + @Test + fun `a note with no event never becomes a row`() { + // Our own push-token answers (448) were indexed with a bare justConsume, which + // does not dispatch that kind, so their notes stayed eventless — and an eventless + // note skipped the kind rules above and rendered "Event is loading or can't be found". + assertEquals(0, visibleCount(list(), Note("d".repeat(64)))) + } } From bd4b18ead01e9731d1f454ddc8601a16af601f08 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:45:12 -0400 Subject: [PATCH 11/37] fix(marmot): Messages tab preview says 'No messages yet' for groups with messages The Messages tab row built its own preview and required an author on the newest note. Derived system rows were attached with a bare 'note.event = row', which never sets the author, so any group whose newest entry was a system row (a join, a rename, an admin change) read 'No messages yet'. Media-only messages showed an empty 'name: ' line. Index derived rows through indexMarmotInnerEvent like every other inner event, and move the Marmot list's preview (system rows, attachments, sender prefix) into marmotGroupPreviewText so both screens use it. Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../marmotGroup/MarmotGroupListScreen.kt | 58 +--------- .../chats/marmotGroup/MarmotGroupPreview.kt | 100 ++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 13 +-- 4 files changed, 107 insertions(+), 70 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3ae1d169d5..192e9523c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3979,9 +3979,9 @@ class Account( // assertion by its sender and is dropped at ingest — so these are // safe to render with attribution. marmotManager.onSystemRowDerived = { groupId, row -> - cache.justConsume(row, null, true) - val note = cache.getOrCreateNote(row.id) - note.event = row + // Indexed like any inner event: a bare `note.event = row` left the row with no + // author, which the Messages tab read as "No messages yet". + val note = marmot.indexMarmotInnerEvent(row).note marmotGroupList.addMessage(groupId, note) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index ec72529de9..44001e54e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -59,7 +59,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.chats.ui.ChatUnreadBadge import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.navigation.Route @@ -73,11 +72,6 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups_desc import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations_desc -import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests @@ -90,14 +84,10 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject -import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @@ -307,53 +297,7 @@ fun MarmotGroupListItem( // to ~100 entries, so counting per recomposition is cheap. val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime } - // A preview has to survive the messages that carry no text: a system row - // keeps its state in tags and MIP-04 media keeps its in imeta, so both used - // to render as a blank second line under the group name. - val myPubKey = accountViewModel.account.signer.pubKey - val previewEvent = newestMessage?.event - val previewBody = - when { - newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet) - previewEvent == null -> stringRes(Res.string.marmot_preview_no_text) - previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated) - // Text first: an attachment usually carries a caption, and showing - // "Attachment" over the words the sender actually wrote would be a - // step back from the raw `content` this replaced. - previewEvent.content.isNotBlank() -> previewEvent.content - hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media) - else -> stringRes(Res.string.marmot_preview_no_text) - } - // Only a genuinely known name earns the prefix: `bestName` returns null - // without metadata, and "a1b2c3d4: hi" is noise, not attribution. - // - // Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a - // plain StateFlow.value read, so a kind:0 arriving after the row composed - // would never reach it. - // - // Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only - // subscribes for a sender the cache already knows, and a sender it does not - // simply goes unprefixed. Creating a User per unknown sender would put a - // metadata REQ behind every row of a list that is mostly strangers' names - // the reader never asked for — a preview line is not worth that. - val senderUser = - previewEvent - ?.pubKey - ?.takeIf { it != myPubKey } - ?.let { LocalCache.getUserIfExists(it) } - val senderName = - if (senderUser != null) { - observeUserInfo(senderUser, accountViewModel).value?.info?.bestName() - } else { - null - } - val previewText = - // A system caption already names its actor, so prefixing one would say it twice. - if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) { - stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody) - } else { - previewBody - } + val previewText = marmotGroupPreviewText(newestMessage, accountViewModel) Row( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt new file mode 100644 index 0000000000..aea9925304 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent + +/** + * The second line of a Marmot group's row: the newest message, prefixed with its + * sender's name. Shared by the Marmot group list and the Messages tab, which had + * drifted: the Messages tab required an author on the note and fell back to + * "No messages yet" for anything else, including a group whose newest entry was + * a system row or an attachment. + */ +@Composable +fun marmotGroupPreviewText( + newestMessage: Note?, + accountViewModel: AccountViewModel, +): String { + // A preview has to survive the messages that carry no text: a system row + // keeps its state in tags and MIP-04 media keeps its in imeta, so both used + // to render as a blank second line under the group name. + val myPubKey = accountViewModel.account.signer.pubKey + val previewEvent = newestMessage?.event + val previewBody = + when { + newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet) + previewEvent == null -> stringRes(Res.string.marmot_preview_no_text) + previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated) + // Text first: an attachment usually carries a caption, and showing + // "Attachment" over the words the sender actually wrote would be a + // step back from the raw `content` this replaced. + previewEvent.content.isNotBlank() -> previewEvent.content + hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media) + else -> stringRes(Res.string.marmot_preview_no_text) + } + // Only a genuinely known name earns the prefix: `bestName` returns null + // without metadata, and "a1b2c3d4: hi" is noise, not attribution. + // + // Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a + // plain StateFlow.value read, so a kind:0 arriving after the row composed + // would never reach it. + // + // Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only + // subscribes for a sender the cache already knows, and a sender it does not + // simply goes unprefixed. Creating a User per unknown sender would put a + // metadata REQ behind every row of a list that is mostly strangers' names + // the reader never asked for — a preview line is not worth that. + val senderUser = + previewEvent + ?.pubKey + ?.takeIf { it != myPubKey } + ?.let { LocalCache.getUserIfExists(it) } + val senderName = + if (senderUser != null) { + observeUserInfo(senderUser, accountViewModel).value?.info?.bestName() + } else { + null + } + val previewText = + // A system caption already names its actor, so prefixing one would say it twice. + if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) { + stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody) + } else { + previewBody + } + + return previewText +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 514b461e3f..7ce56fea01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -99,7 +99,6 @@ import com.vitorpamplona.amethyst.commons.resources.geohash_chat import com.vitorpamplona.amethyst.commons.resources.leave import com.vitorpamplona.amethyst.commons.resources.loading_feed import com.vitorpamplona.amethyst.commons.resources.marmot_group -import com.vitorpamplona.amethyst.commons.resources.marmot_group_no_messages_yet import com.vitorpamplona.amethyst.commons.resources.mute_notifications import com.vitorpamplona.amethyst.commons.resources.muted_chat_content_description import com.vitorpamplona.amethyst.commons.resources.pin_conversation @@ -144,6 +143,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription @@ -515,8 +515,6 @@ private fun MarmotGroupRoomCompose( val relays by chatroom.relays.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() - val author = lastMessage.author - val noteEvent = lastMessage.event val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}" // Prefer the group's own avatar — the plain https link first, then the @@ -529,13 +527,8 @@ private fun MarmotGroupRoomCompose( loadMarmotRelayIcon(relays) } - val lastContent = - if (author != null && noteEvent != null) { - val authorName by observeUserName(author, accountViewModel) - "$authorName: ${noteEvent.content.take(200)}" - } else { - stringRes(Res.string.marmot_group_no_messages_yet) - } + // The row is handed the group's placeholder note when it has no messages. + val lastContent = marmotGroupPreviewText(lastMessage.takeIf { it.event != null }, accountViewModel) val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(chatroom.nostrGroupId)).collectAsStateWithLifecycle() From 48dc0b45d77a3a300403a44c4cd9d9659124a8a4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:46:44 -0400 Subject: [PATCH 12/37] fix(marmot): name an unnamed group after the people in it, not 'Group c4f0426f' White Noise creates 1:1 chats without a group name. The Messages tab titled them with the group id prefix and pictured a relay icon, and the Marmot list, chat header and Group Info named and pictured the members including ourselves. Name an unnamed group after its other members (up to four, like a NIP-17 room), and picture them in the Messages tab when the group has neither a name nor an avatar. A named group or one with its own avatar keeps it. Co-Authored-By: Claude Opus 5.5 --- .../marmotGroup/MarmotGroupChatScreen.kt | 2 +- .../marmotGroup/MarmotGroupInfoScreen.kt | 9 +++-- .../marmotGroup/MarmotGroupListScreen.kt | 2 +- .../chats/marmotGroup/MarmotGroupPreview.kt | 35 +++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 21 ++++++++++- 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index 6b60e7c9cf..8a90a9ee79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -71,7 +71,7 @@ fun MarmotGroupChatScreen( val displayName by chatroom.displayName.collectAsStateWithLifecycle() val memberCount by chatroom.memberCount.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle() - val memberPubkeys = remember(members) { members.map { it.pubkey } } + val memberPubkeys = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) } DisappearingScaffold( isInvertedLayout = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 7321b36d9f..a0779f5df5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -102,7 +102,6 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_privilege import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_title import com.vitorpamplona.amethyst.commons.resources.marmot_group_disbanded_toast import com.vitorpamplona.amethyst.commons.resources.marmot_group_disbanding_toast -import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name import com.vitorpamplona.amethyst.commons.resources.marmot_group_info_title import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_required import com.vitorpamplona.amethyst.commons.resources.marmot_leave_group @@ -272,7 +271,13 @@ fun MarmotGroupInfoScreen( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = displayName ?: stringRes(Res.string.marmot_group_fallback_name, nostrGroupId.take(8)), + text = + marmotGroupTitle( + displayName, + remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) }, + nostrGroupId, + accountViewModel, + ), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 44001e54e4..7adda762bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -287,7 +287,7 @@ fun MarmotGroupListItem( ) { val displayName by chatroom.displayName.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle() - val memberPubkeys = remember(members) { members.map { it.pubkey } } + val memberPubkeys = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) } val newestMessage = chatroom.newestMessage val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(groupId)).collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt index aea9925304..2c297319c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt @@ -21,9 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media @@ -34,7 +37,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey /** * The second line of a Marmot group's row: the newest message, prefixed with its @@ -98,3 +103,33 @@ fun marmotGroupPreviewText( return previewText } + +/** + * The members a group's row or header should picture and name: everyone but us, like a + * NIP-17 room. A group of just us falls back to us, so a fresh group still has a face. + */ +fun marmotOtherMembers( + members: List, + myPubKey: HexKey, +): List { + val all = members.map { it.pubkey }.distinct() + return all.filter { it != myPubKey }.ifEmpty { all } +} + +/** + * A group's title: its name, else the other members' names. White Noise creates 1:1 + * chats without a name, and "Group c4f0426f…" told the reader nothing about who was + * on the other side. + */ +@Composable +fun marmotGroupTitle( + displayName: String?, + otherMembers: List, + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, +): String { + if (!displayName.isNullOrBlank()) return displayName + if (otherMembers.isEmpty()) return stringRes(Res.string.marmot_group_fallback_name, nostrGroupId.take(8)) + val names = otherMembers.take(4).map { key(it) { observeUserNameByHex(it, accountViewModel) } } + return names.joinToString(", ") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 7ce56fea01..dd8634d812 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -144,6 +144,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimeli import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupTitle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription @@ -515,7 +517,9 @@ private fun MarmotGroupRoomCompose( val relays by chatroom.relays.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() - val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}" + val members by chatroom.members.collectAsStateWithLifecycle() + val otherMembers = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) } + val groupName = marmotGroupTitle(displayName, otherMembers, chatroom.nostrGroupId, accountViewModel) // Prefer the group's own avatar — the plain https link first, then the // encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon @@ -532,6 +536,21 @@ private fun MarmotGroupRoomCompose( val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(chatroom.nostrGroupId)).collectAsStateWithLifecycle() + val hasGroupFace = avatarUrl != null || image != null || !displayName.isNullOrBlank() + if (!hasGroupFace && otherMembers.isNotEmpty()) { + // An unnamed group without an avatar (White Noise's 1:1 chats) is about its people: + // show them, as a NIP-17 room does, instead of a relay icon. + ChannelName( + channelPicture = { NonClickableUserPictures(userHexList = otherMembers, size = Size55dp, accountViewModel = accountViewModel) }, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, MaterialSymbols.Lock, Res.string.marmot_group, modifier) }, + channelLastTime = lastMessage.createdAt(), + channelLastContent = lastContent, + hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, + onClick = { nav.nav(Route.MarmotGroupChat(chatroom.nostrGroupId)) }, + ) + return + } + ChannelName( channelIdHex = chatroom.nostrGroupId, channelPicture = channelPicture, From a4fc219368426e543810472c530764d72b5fb91a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:47:30 -0400 Subject: [PATCH 13/37] feat(marmot): hide the other person's name and picture in a 1:1 group chat NIP-17 bubbles drop the author line when the conversation has one counterpart, because the message itself names its participants. A Marmot inner kind:9 names none, so every bubble in a two-person group repeated the other person's name and avatar. The chat screen knows the member count; it now tells the bubbles through LocalChatIsOneOnOne. Co-Authored-By: Claude Opus 5.5 --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 14 ++++++++- .../chats/marmotGroup/MarmotGroupChatView.kt | 31 ++++++++++++------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index ab207a6f28..3d180334b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -241,9 +241,10 @@ fun NormalChatNote( // A geohash chat asks own messages to still show the author line (which identity posted), so the // usual "hide the name on my own bubbles" shortcut is opt-out there. val showSelfAuthorName = LocalChatShowSelfAuthorName.current + val isOneOnOne = LocalChatIsOneOnOne.current val drawAuthorInfo by - remember(note, isLoggedInUser, showSelfAuthorName) { + remember(note, isLoggedInUser, showSelfAuthorName, isOneOnOne) { derivedStateOf { val noteEvent = note.event when { @@ -251,6 +252,9 @@ fun NormalChatNote( // which never draws the user's own author info). isLoggedInUser -> showSelfAuthorName && noteEvent !is EncryptedDmEvent + // The screen knows it's just the two of you. + isOneOnOne -> false + // never shows the user's pictures noteEvent is EncryptedDmEvent -> false @@ -505,6 +509,14 @@ val LocalChatDisplayNameResolver = compositionLocalOf<((Note) -> String?)?> { nu */ val LocalChatShowSelfAuthorName = compositionLocalOf { false } +/** + * Whether the conversation is known to be one-on-one even though its messages don't say so. + * A NIP-17 message carries its participants ([ChatroomKeyable]) and hides the other person's + * name and picture when there is only one; a Marmot group's inner kind:9 carries nothing, so the + * screen, which knows the member count, says it here. + */ +val LocalChatIsOneOnOne = compositionLocalOf { false } + /** * The geohash of the location room currently open, or null outside one. Every message in that room * repeats the room's own cell in its `g` tag, so the bubble footer suppresses this one geohash — diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 4269133bc2..6c5f014601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatIsOneOnOne import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader @@ -157,18 +159,23 @@ fun MarmotGroupChatView( .fillMaxHeight() .weight(1f, true), ) { - RefreshingChatroomFeedView( - feedContentState = feedViewModel.feedState, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = marmotGroupLastReadRoute(nostrGroupId), - onWantsToReply = { note -> newMessageModel.reply(note) }, - onWantsToEditDraft = { }, - // kind:1210 rows sit in the conversation in order but are - // group-state captions rather than messages, so they get their - // own centered style instead of a bubble. - rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) }, - ) + // Two members is a 1:1 chat: drop the other person's name and picture from every + // bubble, as a NIP-17 conversation does. Zero means not loaded yet, so keep them. + val memberCount by chatroom.memberCount.collectAsStateWithLifecycle() + CompositionLocalProvider(LocalChatIsOneOnOne provides (memberCount in 1..2)) { + RefreshingChatroomFeedView( + feedContentState = feedViewModel.feedState, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = marmotGroupLastReadRoute(nostrGroupId), + onWantsToReply = { note -> newMessageModel.reply(note) }, + onWantsToEditDraft = { }, + // kind:1210 rows sit in the conversation in order but are + // group-state captions rather than messages, so they get their + // own centered style instead of a bubble. + rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) }, + ) + } } Spacer(modifier = DoubleVertSpacer) From a7ae40242abecac4dbd790ac18523ed8d42ae203 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 20:49:19 -0400 Subject: [PATCH 14/37] fix(chat): a quoted reply no longer stretches its bubble across the screen Every bubble's outer row filled the available width so it could sit on its side of the screen. A quoted reply is a bubble drawn inside another bubble, and filling the width there pushed the outer bubble to its maximum (85% of the screen), so a one-word answer quoting a one-word message spanned the chat. This affected every chat that renders replies (NIP-17, public chats, Marmot, Concord, Buzz). The quoted bubble now wraps its content. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/commons/chats/ui/ChatBubbleLayout.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt index 3583623b52..c04a634407 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt @@ -300,8 +300,11 @@ fun ChatBubbleLayout( Modifier } + // A top-level bubble's row spans the screen so the bubble can sit on its side. A quoted + // reply is drawn INSIDE another bubble, where filling the width pushed that bubble out to + // its maximum: a one-word answer to a one-word message stretched across the screen. Row( - modifier = Modifier.fillMaxWidth().then(swipeModifier), + modifier = (if (innerQuote) Modifier else Modifier.fillMaxWidth()).then(swipeModifier), horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, ) { InnerChatBubble( From 9d856315ade41af7ea965d9048da9c4f8f24c9fe Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 21:13:23 -0400 Subject: [PATCH 15/37] fix(marmot): new groups carry the encrypted-media policy so White Noise can send media Groups created here left the encrypted-media-v2 component (0x800b) off, and White Noise (MDK) refuses to send any attachment in a group without it: InvalidMediaReference, 'group does not require encrypted media'. MDK treats the component's presence as the requirement. After the initial metadata commit, while the creator is still the only member, commit the policy with the account's Blossom servers. Joiners get it in their Welcome, so no member ever has to apply a later commit before sharing media. The policy names the account's own Blossom servers, or, for an account without a list, the default list the upload picker offers and falls back through (the Group Info "Use encrypted attachments" button now works for those accounts too, instead of asking them to add a server first). Best-effort: if the commit fails the group is still created, and text and legacy MIP-04 media keep working. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 32 +++++++++++++++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +-- .../chats/marmotGroup/CreateGroupScreen.kt | 13 ++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 116e36b8b1..d31be56224 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 @@ -962,13 +963,40 @@ class AccountMarmotActions( * * The endpoints come from the account's own Blossom server list, because a * policy naming servers the uploader does not use would describe a group - * nobody can actually post media to. + * nobody can actually post media to. An account without one uploads to the + * default list (the same one the upload picker offers and falls back + * through), so that is what its policy names. */ + fun marmotMediaPolicyServers(): List = + account.blossomServers.flow.value + .ifEmpty { + account.blossomServers.hostNameFlow.value + .filter { it.type == ServerType.Blossom } + .map { it.baseUrl } + }.mapNotNull { normalizedPolicyBaseUrl(it) } + .distinct() + .take(EncryptedMediaPolicyV2.MAX_ENTRIES) + + /** + * [url] in the byte-exact form the media policy requires, or null when it has none. + * The component rejects a base URL that isn't its own WHATWG serialization, so the + * everyday spelling without a trailing slash (`https://cdn.nostrcheck.me`) failed the + * whole commit. + */ + private fun normalizedPolicyBaseUrl(url: String): String? = + try { + MarmotWebUrl.normalize(url, allowHttp = true, label = "base_url").also { + EncryptedMediaPolicyV2.requireNormalizedBaseUrl(it) + } + } catch (e: IllegalArgumentException) { + null + } + suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) { val manager = account.marmotManager ?: return if (!account.isWriteable()) return - val servers = account.blossomServers.flow.value + val servers = marmotMediaPolicyServers() require(servers.isNotEmpty()) { "Cannot enable encrypted media without at least one Blossom server configured" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0439256b55..6439cc7e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2537,9 +2537,7 @@ class AccountViewModel( fun marmotUsesEncryptedMediaV2(nostrGroupId: String): Boolean = account.marmotManager?.encryptedMediaPolicy(nostrGroupId) != null /** True when this account has somewhere to upload a group's encrypted media. */ - fun hasBlossomServers(): Boolean = - account.blossomServers.flow.value - .isNotEmpty() + fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty() suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) { account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 67eb18089d..cae9a3f162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -67,9 +67,11 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException @Composable fun CreateGroupScreen( @@ -136,6 +138,17 @@ fun CreateGroupScreen( description = groupDescription.trim(), icon = iconChange, ) + // Commit the encrypted-media policy while we are the only member. White Noise + // (MDK) refuses to send any attachment in a group without one + // ("group does not require encrypted media"), and joiners learn it from the + // Welcome, so nobody has to apply a later commit to be able to share media. + // Best-effort: a group without it still works for text and legacy MIP-04. + try { + accountViewModel.enableMarmotEncryptedMediaV2(nostrGroupId) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("CreateGroupScreen") { "Could not enable encrypted media for $nostrGroupId: ${e.message}" } + } nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class) } catch (e: Exception) { isCreating = false From c2fda6aae27248e5fade04d8777668593c261bc9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 21:20:57 -0400 Subject: [PATCH 16/37] feat(uploads): fall back through the Blossom server list when an upload fails An upload went to the one server picked in the dialog and failed there. Which servers accept what isn't visible in advance: primal, azzamo and blossom.band answer an encrypted (application/octet-stream) blob with 415, the paid ones with 402, and any server can be down. So an encrypted chat attachment failed depending on which default happened to be selected. The Blossom settings screen already told users uploads 'try each server from the top down'; nothing did. Blossom uploads, plain and encrypted, now try the picked server first and then the rest of the list the picker offered (the user's kind-10063 servers, or the defaults when they have none), top down, skipping duplicates. An encrypted upload re-sends the same ciphertext, so its key, nonce and hash stay valid wherever it lands. A read-only login stops at once; if every server fails, the selected server's error is shown. NIP-96 and NIP-95 keep a single target. White Noise (MDK 0.10.4) fetches any safe https Blossom URL, not only the group policy's endpoints, so a fallback server is readable by it. The shared JVM UploadOrchestrator (desktop app and amy) gets the same behaviour: upload/uploadEncrypted take the account's other servers and try them in order; the desktop DM, note composer and profile-picture uploads pass the list. Its auth header isn't server-scoped, so one serves every attempt. Co-Authored-By: Claude Opus 5.5 --- .../service/uploads/UploadOrchestrator.kt | 39 +++++++++- .../model/mediaServers/UploadFallback.kt | 47 +++++++++++ .../model/mediaServers/UploadFallbackTest.kt | 55 +++++++++++++ .../service/upload/UploadOrchestrator.kt | 64 ++++++++++++--- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 4 + .../amethyst/desktop/ui/chats/ChatPane.kt | 2 +- .../desktop/ui/profile/EditProfileScreen.kt | 5 +- .../service/upload/UploadOrchestratorTest.kt | 77 +++++++++++++++++++ 8 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 5728bab2e0..193a275694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -25,6 +25,7 @@ import android.net.Uri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType +import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required @@ -202,6 +203,32 @@ class UploadOrchestrator { } } + /** + * Tries [selected], then the rest of the servers the picker offered (see + * [blossomUploadOrder]) until one stores the blob. A failure that no other server would + * fix (a read-only login) stops at once. When every server fails, the error shown is the + * selected server's: that is the one the user chose and will recognize. + */ + private suspend fun uploadBlossomWithFallback( + selected: ServerName, + account: Account, + uploadTo: suspend (serverBaseUrl: String) -> UploadingFinalState, + ): UploadingFinalState { + val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value) + var firstError: UploadingState.Error? = null + for (server in order) { + when (val result = uploadTo(server.baseUrl)) { + is UploadingState.Finished -> return result + is UploadingState.Error -> { + if (firstError == null) firstError = result + if (result.errorResource == Res.string.login_with_a_private_key_to_be_able_to_upload) return result + Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server") + } + } + } + return firstError!!.also { updateState(0.0, it) } + } + private suspend fun uploadBlossom( fileUri: Uri, contentType: String?, @@ -482,7 +509,10 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl -> + uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context) + } } } finally { deleteTempUri(finalUri, uri) @@ -527,7 +557,12 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + // The same encrypted file goes to every server tried, so its key, nonce and + // hash stay valid whichever one ends up holding it. + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl -> + uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + } } } finally { deleteTempUri(encrypted.uri, uri) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt new file mode 100644 index 0000000000..fd16921495 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.mediaServers + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl + +/** + * The Blossom servers an upload tries, in order: the one the user picked, then the rest + * of the list the picker offered them (their kind-10063 servers, or the defaults when + * they have none), top down. + * + * Servers differ in what they accept, and nothing tells the user in advance: primal, + * azzamo and blossom.band answer an encrypted (application/octet-stream) blob with + * 415, some demand payment, some are simply down. With a single target, picking the + * wrong one meant a failed upload and a retry by hand; the settings screen already told + * users uploads "try each server from the top down". + * + * Blossom only: a NIP-96 or NIP-95 upload keeps its single target, because falling back + * from one would change the kind of event the upload produces. + */ +fun blossomUploadOrder( + selected: ServerName, + offered: List, +): List { + if (selected.type != ServerType.Blossom) return listOf(selected) + val seen = mutableSetOf(BlossomServerUrl.domain(selected.baseUrl)) + val rest = offered.filter { it.type == ServerType.Blossom && seen.add(BlossomServerUrl.domain(it.baseUrl)) } + return listOf(selected) + rest +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt new file mode 100644 index 0000000000..9aeaee304e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.mediaServers + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UploadFallbackTest { + private val band = ServerName("Nostr.Build", "https://blossom.band/") + private val primal = ServerName("Primal", "https://blossom.primal.net/") + private val yaki = ServerName("YakiHonne", "https://blossom.yakihonne.com/") + + @Test + fun `the picked server goes first and the rest follow in list order`() { + assertEquals(listOf(primal, band, yaki), blossomUploadOrder(primal, listOf(band, primal, yaki))) + } + + @Test + fun `a server is never tried twice, even under another spelling`() { + val primalAgain = ServerName("primal", "https://BLOSSOM.PRIMAL.NET") + assertEquals(listOf(primal, band), blossomUploadOrder(primal, listOf(primalAgain, band, band))) + } + + @Test + fun `a server picked from outside the list is still first`() { + val custom = ServerName("mine", "https://blobs.example.com") + assertEquals(listOf(custom, band), blossomUploadOrder(custom, listOf(band))) + } + + @Test + fun `non-Blossom uploads do not fall back`() { + val nip96 = ServerName("nostr.build", "https://nostr.build", ServerType.NIP96) + assertEquals(listOf(nip96), blossomUploadOrder(nip96, listOf(band, nip96))) + // Nor does a Blossom upload fall back onto a NIP-96 server. + assertEquals(listOf(band), blossomUploadOrder(band, listOf(nip96))) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 9f28a12765..c8b47520d9 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -24,12 +24,15 @@ import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.Reencode import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import java.io.File +import kotlin.coroutines.cancellation.CancellationException data class UploadResult( val blossom: BlossomUploadResult, @@ -76,6 +79,8 @@ class UploadOrchestrator( quality: CompressionQuality? = null, bypassReencode: Boolean = false, preCompressed: File? = null, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): UploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -133,12 +138,14 @@ class UploadOrchestrator( // 5. Upload. val result = - client.upload( - file = finalFile, - contentType = metadata.mimeType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + file = finalFile, + contentType = metadata.mimeType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return UploadResult(blossom = result, metadata = metadata) } finally { @@ -171,6 +178,8 @@ class UploadOrchestrator( // When true it's uploaded with the real media type — less private, but // required by strict Blossom servers that reject octet-stream (HTTP 415). declareRealMimeType: Boolean = false, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): EncryptedUploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -230,13 +239,17 @@ class UploadOrchestrator( // 415) also work, at the cost of leaking the media category. val uploadContentType = if (declareRealMimeType) metadata.mimeType else "application/octet-stream" + // The same ciphertext goes to every server tried, so the cipher and the + // encrypted hash stay valid wherever it lands. val result = - client.upload( - bytes = encrypted, - contentType = uploadContentType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + bytes = encrypted, + contentType = uploadContentType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return EncryptedUploadResult( blossom = result, @@ -251,4 +264,31 @@ class UploadOrchestrator( } } } + + /** + * [serverBaseUrl] first, then each of [fallbacks] (skipping duplicates by domain) until + * one accepts. Servers differ in what they take -- several answer an opaque encrypted + * blob with 415, paid ones with 402 -- and the user can't see that in advance. The auth + * header is not server-scoped, so the same one serves every attempt. When all fail, the + * first server's error is thrown: that is the server the user chose. + */ + private suspend fun uploadToFirstAccepting( + serverBaseUrl: String, + fallbacks: List, + upload: suspend (String) -> BlossomUploadResult, + ): BlossomUploadResult { + val seen = mutableSetOf() + val order = (listOf(serverBaseUrl) + fallbacks).filter { seen.add(BlossomServerUrl.domain(it)) } + var firstError: Exception? = null + for (server in order) { + try { + return upload(server) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (firstError == null) firstError = e + Log.w("UploadOrchestrator") { "Upload to $server failed (${e.message}), trying the next server" } + } + } + throw firstError!! + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index fe7a29c773..c44212493f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -336,6 +336,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -350,6 +351,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -361,6 +363,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -371,6 +374,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c34aea979b..cd139fe605 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -1410,7 +1410,7 @@ private suspend fun sendEncryptedFiles( // unsupported file can't abort the whole send. val fileQuality = if (file.extension.lowercase() in IMAGE_EXTENSIONS) quality else null val result = - orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType) + orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) val url = result.blossom.url ?: continue val template = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 317b36f1c4..44cb45d946 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -172,7 +172,8 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = LocalBlossomServers.current?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + val blossomServers = LocalBlossomServers.current + val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, @@ -182,7 +183,7 @@ fun EditProfileDialog( scope.launch(Dispatchers.IO) { setUploading(true) try { - val result = orchestrator.upload(file, null, serverBaseUrl, account.signer) + val result = orchestrator.upload(file, null, serverBaseUrl, account.signer, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) result.blossom.url?.let { onUrl(it) } } catch (e: CancellationException) { throw e diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt index 04ba37a589..326b036f98 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -222,4 +223,80 @@ class UploadOrchestratorTest { file.delete() } } + + @Test + fun encryptedUploadFallsBackToTheNextServerWithTheSameCiphertext() = + runTest { + // A server that refuses opaque blobs (415) must not fail the upload while + // another listed server would take it -- and the retry has to send the very + // bytes the cipher and hash describe. + val mockClient = mockk() + val servers = mutableListOf() + val bodies = mutableListOf() + coEvery { + mockClient.upload(bytes = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { + val server = arg(2) + servers += server + bodies += arg(0) + if (server.contains("primal")) { + throw IllegalStateException("Unsupported Media Type") + } + BlossomUploadResult(url = "$server/blob") + } + + val file = File.createTempFile("test_", ".bin") + file.deleteOnExit() + file.writeBytes(ByteArray(64) { it.toByte() }) + val mockSigner = mockk(relaxed = true) + + try { + val result = + UploadOrchestrator(mockClient).uploadEncrypted( + file = file, + cipher = AESGCM(), + serverBaseUrl = "https://blossom.primal.net", + signer = mockSigner, + fallbackServerBaseUrls = listOf("https://BLOSSOM.PRIMAL.NET/", "https://nostr.download"), + ) + + assertEquals(listOf("https://blossom.primal.net", "https://nostr.download"), servers) + assertTrue(bodies[0].contentEquals(bodies[1])) + assertEquals("https://nostr.download/blob", result.blossom.url) + } finally { + file.delete() + } + } + + @Test + fun whenEveryServerFailsTheSelectedServersErrorIsThrown() = + runTest { + val mockClient = mockk() + coEvery { + mockClient.upload(file = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { throw IllegalStateException("refused by ${arg(2)}") } + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("content") + val mockSigner = mockk(relaxed = true) + + try { + val error = + kotlin + .runCatching { + UploadOrchestrator(mockClient).upload( + file = file, + alt = null, + serverBaseUrl = "https://a.example", + signer = mockSigner, + stripExif = false, + fallbackServerBaseUrls = listOf("https://b.example"), + ) + }.exceptionOrNull() + assertEquals("refused by https://a.example", error?.message) + } finally { + file.delete() + } + } } From 4ac9702e3b69e58e1c882b086c02fb2d40bd4c08 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 21:24:09 -0400 Subject: [PATCH 17/37] feat(marmot): edit your own messages in a Marmot group Amethyst already applied kind-1009 edits from White Noise, but offered no way to send one. My own text messages in a Marmot group now get the Edit tile in the chat action sheet. The composer is prefilled with the text the message currently shows (its latest edit, if any), an 'Editing message' banner offers cancel, and sending publishes a kind-1009 edit through the same show-then-publish path as a new message, so it overlays the bubble at once. Media messages are excluded: their content is the attachment locator. MarmotManager.buildMessageEditRumor splits the rumor out of buildMessageEdit for callers that publish through their own path. The banner Concord used is now the shared EditingMessageBanner. Co-Authored-By: Claude Opus 5.5 --- .../ui/screen/loggedIn/AccountViewModel.kt | 17 +++++ .../chats/feed/ChatMessageActionSheet.kt | 10 ++- .../chats/marmotGroup/MarmotGroupChatView.kt | 6 ++ .../send/MarmotNewMessageViewModel.kt | 28 ++++++++ .../concord/ConcordChannelScreen.kt | 28 +------- .../chats/utils/EditingMessageBanner.kt | 71 +++++++++++++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 25 ++++--- 7 files changed, 150 insertions(+), 35 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 6439cc7e86..f49fcd97f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2473,6 +2473,23 @@ class AccountViewModel( deliverMarmotGroupMessage(nostrGroupId, innerEvent) } + /** + * Replace the text of my own Marmot message [target] with a kind:1009 edit. The edit + * overlays the original everywhere it is shown (here, and in White Noise), and goes + * through the same show-then-publish path as a new message, so a failed send shows on + * the edit's delivery state rather than silently. + */ + suspend fun sendMarmotGroupMessageEdit( + nostrGroupId: String, + target: Note, + text: String, + ) { + val tagger = NewMessageTagger(text, null, null, this) + tagger.run() + val manager = account.marmotManager ?: return + deliverMarmotGroupMessage(nostrGroupId, manager.buildMessageEditRumor(target.idHex, tagger.message)) + } + /** * Show [innerEvent] in the group's chat now and publish it on the account * scope. Shared by every Marmot send that originates in the UI. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index bf0bbd0eb9..85cdabb4ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -289,7 +289,15 @@ fun ChatMessageActionSheet( note.event is ChatEvent && isMine && note.inGatherers?.any { it is ConcordChannel } == true - if (canEditBuzz || canEditConcord) { + // Marmot: my own text message in a group -> a kind-1009 edit inside the group. A + // media message's content is its locator, so editing it would break the attachment. + val canEditMarmot = + onWantsToEditChatMessage != null && + note.event is ChatEvent && + isMine && + note.event?.tags?.none { it.isNotEmpty() && it[0] == "imeta" } == true && + accountViewModel.account.marmot.marmotGroupOf(note) != null + if (canEditBuzz || canEditConcord || canEditMarmot) { SectionDivider() TileRow { val label = if (canEditBuzz) Res.string.buzz_edit_message else Res.string.edit_message diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 6c5f014601..399c647b78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList @@ -170,6 +171,7 @@ fun MarmotGroupChatView( routeForLastRead = marmotGroupLastReadRoute(nostrGroupId), onWantsToReply = { note -> newMessageModel.reply(note) }, onWantsToEditDraft = { }, + onWantsToEditChatMessage = { note -> newMessageModel.editMarmotMessage(note) }, // kind:1210 rows sit in the conversation in order but are // group-state captions rather than messages, so they get their // own centered style instead of a bubble. @@ -242,6 +244,10 @@ fun MarmotGroupMessageComposer( } } + newMessageModel.editingMessage.value?.let { + EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() }) + } + Column(modifier = EditFieldModifier) { newMessageModel.userSuggestions?.let { ShowUserSuggestionList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt index fd5804fc87..36e1beb9ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -59,6 +60,9 @@ open class MarmotNewMessageViewModel : ViewModel() { val message = TextFieldState() val replyTo = mutableStateOf(null) + // My own message being replaced; the next send publishes a kind:1009 edit of it. + val editingMessage = mutableStateOf(null) + var uploadState by mutableStateOf(null) var userSuggestions: UserSuggestionState? = null @@ -83,11 +87,26 @@ open class MarmotNewMessageViewModel : ViewModel() { this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) this.message.clearText() this.replyTo.value = null + this.editingMessage.value = null } } fun reply(note: Note) { replyTo.value = note + editingMessage.value = null + } + + /** Enter edit mode for my own [note], prefilled with the text it currently shows. */ + fun editMarmotMessage(note: Note) { + replyTo.value = null + editingMessage.value = note + val current = note.latestMarmotEdit()?.event?.content ?: note.event?.content ?: "" + message.setTextAndPlaceCursorAtEnd(current) + } + + fun cancelEdit() { + editingMessage.value = null + message.clearText() } fun clearReply() { @@ -137,6 +156,15 @@ open class MarmotNewMessageViewModel : ViewModel() { val text = message.text.toString().trim() if (text.isEmpty()) return + val editing = editingMessage.value + if (editing != null) { + accountViewModel.sendMarmotGroupMessageEdit(groupId, editing, text) + editingMessage.value = null + onUiThread { message.clearText() } + userSuggestions?.reset() + return + } + // Capture id+pubKey snapshot before suspending so a slow send // doesn't race a user-cleared reply state. val parentEvent = replyTo.value?.event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 81f5853241..47838e144b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -65,9 +65,7 @@ import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back -import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only -import com.vitorpamplona.amethyst.commons.resources.concord_editing_banner import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one @@ -108,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.Ch import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress @@ -455,30 +454,7 @@ private fun ConcordMessageComposer( // Edit mode: a banner reminding the user the next send replaces this message (a kind-1010 // edit on the channel plane), with an X to abandon the edit and clear the field. newMessageModel.editingMessage.value?.let { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - SymbolIcon( - symbol = MaterialSymbols.Edit, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(Res.string.concord_editing_banner), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f).padding(start = 8.dp), - ) - IconButton(onClick = { newMessageModel.cancelEdit() }) { - SymbolIcon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(Res.string.cancel), - modifier = Modifier.size(16.dp), - ) - } - } + EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() }) } Column(modifier = EditFieldModifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt new file mode 100644 index 0000000000..a000abf5ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_editing_banner +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Shown above a chat composer in edit mode: the next send replaces one of my messages + * instead of posting a new one. The X abandons the edit and clears the field. + */ +@Composable +fun EditingMessageBanner(onCancel: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(Res.string.concord_editing_banner), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + IconButton(onClick = onCancel) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(Res.string.cancel), + modifier = Modifier.size(16.dp), + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 2b59649dcc..da70cac22b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent @@ -743,6 +744,21 @@ class MarmotManager( /** The group's current MLS epoch, which the stream key context binds. */ fun currentEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch + /** + * The kind:1009 edit rumor alone, for a caller that sends it through its own + * show-then-publish path (the app's composer) rather than [buildMessageEdit]. + */ + fun buildMessageEditRumor( + targetEventId: HexKey, + replacement: String, + ): Event { + val template = + eventTemplate(kind = MarmotAppEvent.KIND_EDIT, description = replacement) { + addUnique(arrayOf("e", targetEventId)) + } + return RumorAssembler.assembleRumor(signer.pubKey, template) + } + /** * Build a kind:1009 edit that replaces the text of a prior message. * @@ -762,14 +778,7 @@ class MarmotManager( replacement: String, persistOwn: Boolean = true, ): TextMessageBundle { - val template = - com.vitorpamplona.quartz.nip01Core.signers - .eventTemplate(kind = MarmotAppEvent.KIND_EDIT, description = replacement) { - addUnique(arrayOf("e", targetEventId)) - } - val innerEvent = - com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler - .assembleRumor(signer.pubKey, template) + val innerEvent = buildMessageEditRumor(targetEventId, replacement) val outbound = buildGroupMessage(nostrGroupId, innerEvent) if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) From a2ad02f58c01832fc068da080c3dcb4b2eee43c6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 21:49:33 -0400 Subject: [PATCH 18/37] fix(marmot): retry an invite whose first attempt failed instead of waiting for a restart A Welcome is only processed when its seal is first opened. When a relay re-delivered the wrap (every re-subscription replays the last two days of gift wraps), processExistingSeal handed the stored kind-444 rumor to the generic event processor, which does nothing with it. So a Welcome that failed once (the signer busy, storage not ready, an exception inside the join) stayed unjoined until the next app start opened the seal again. - A replayed seal holding a Welcome goes back through the Welcome flow. - A Welcome that joined, found us already joined, or names a KeyPackage we never held is marked terminally ingested (the policy amy already uses), so replays return at once and never re-notify. - Any other failure is retried on the account scope after 30s, 2m and 10m, with at most one pending retry chain per Welcome. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 14 ++++++ .../loggedIn/DecryptAndIndexProcessor.kt | 47 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index d31be56224..dbc7084b4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 @@ -89,6 +91,18 @@ class AccountMarmotActions( /** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */ private var lastOwnerCheck: Pair? = null + // Welcome rumor ids with a retry already scheduled, so a relay re-delivering the same + // wrap while one waits does not start a second chain of retries. + private val welcomeRetries = mutableSetOf() + private val welcomeRetriesLock = KmpLock() + + /** True when [welcomeId] had no retry pending and now has one. */ + fun claimWelcomeRetry(welcomeId: HexKey): Boolean = welcomeRetriesLock.withLock { welcomeRetries.add(welcomeId) } + + fun releaseWelcomeRetry(welcomeId: HexKey) { + welcomeRetriesLock.withLock { welcomeRetries.remove(welcomeId) } + } + /** * Resolve the relay set for a Marmot group. Prefer the relays carried in * the MLS GroupContext metadata so every member converges on the same diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 83d907fb90..af8f947214 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -55,6 +55,9 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -394,11 +397,15 @@ class GiftWrapEventHandler( private suspend fun processMarmotWelcomeFlow( innerEvent: Event, account: Account, + attempt: Int = 0, ) { val manager = account.marmotManager ?: return if (innerEvent !is WelcomeEvent) { return } + // A Welcome that already joined (or never can) is done. Replays of its wrap are routine: + // every re-subscription re-delivers the last two days of gift wraps. + if (manager.isTerminallyIngested(innerEvent.id)) return // "h" tag is optional per MIP-02 — some senders (e.g. whitenoise-rs) omit it. // nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension instead. @@ -407,6 +414,7 @@ private suspend fun processMarmotWelcomeFlow( when (result) { is WelcomeResult.Joined -> { + manager.markTerminallyIngested(innerEvent.id) Log.d("MarmotDbg") { "processMarmotWelcomeFlow: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}" } @@ -437,6 +445,7 @@ private suspend fun processMarmotWelcomeFlow( } is WelcomeResult.AlreadyJoined -> { + manager.markTerminallyIngested(innerEvent.id) // Benign replay of a gift-wrapped Welcome (kind:1059) we already // processed in a prior session — the relay is just re-delivering // it after app restart. Log at DEBUG, not WARN. @@ -446,11 +455,39 @@ private suspend fun processMarmotWelcomeFlow( } is WelcomeResult.Error -> { - Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" } + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR (attempt ${attempt + 1}) ${result.message}" } + if (result.message.contains("No matching KeyPackageBundle")) { + // Bundles are generated before their KeyPackage is published, so a Welcome + // for one we never held can never become processable. + manager.markTerminallyIngested(innerEvent.id) + } else { + scheduleWelcomeRetry(innerEvent, account, attempt) + } } } } +/** + * Backoff for a Welcome that failed for a reason that can pass (the signer was busy, the + * KeyPackage store was still loading, a relay round trip failed). Before this a failed + * Welcome was only retried on the next app start: a replayed wrap skipped the flow. + */ +private val WELCOME_RETRY_DELAYS_MS = longArrayOf(30_000L, 120_000L, 600_000L) + +private fun scheduleWelcomeRetry( + welcome: WelcomeEvent, + account: Account, + attempt: Int, +) { + if (attempt >= WELCOME_RETRY_DELAYS_MS.size) return + if (!account.marmot.claimWelcomeRetry(welcome.id)) return + account.scope.launch(Dispatchers.IO) { + delay(WELCOME_RETRY_DELAYS_MS[attempt]) + account.marmot.releaseWelcomeRetry(welcome.id) + processMarmotWelcomeFlow(welcome, account, attempt + 1) + } +} + class SealEventHandler( private val account: Account, private val cache: LocalCache, @@ -529,7 +566,13 @@ class SealEventHandler( cache.copyRelaysFromTo(publicNote, rumorId) val innerRumorNote = cache.getOrCreateNote(rumorId) innerRumorNote.event?.let { innerRumor -> - eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) + // A re-delivered Welcome goes back through the MLS flow, which returns at once + // when it already joined; one that failed the first time gets another chance. + if (MarmotInboundProcessor.isWelcomeEvent(innerRumor)) { + processMarmotWelcomeFlow(innerRumor, account) + } else { + eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) + } } } } From bb796b71fb6f155437fe81100e329203148d5688 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 22:00:25 -0400 Subject: [PATCH 19/37] feat(marmot): notice a group this device fell out of sync with, and offer a way back A device that forked off a group's epoch chain (the own-commit echo bug fixed in #4232 left such groups behind) never recovers: the other members hold no copy of its epoch, it cannot apply theirs, and every message they send fails with 'decrypts on no canonical epoch'. Nothing told the user; the chat just went quiet. MarmotDesyncDetector flags a group when peer messages NEWER than the last event this device decrypted there keep failing that way (3 distinct events over at least 60s). History from before we joined or past retention fails the same way but is older than that baseline, so it never counts; one successful decrypt clears the flag. The baseline is seeded on join/create and from the newest persisted message on restore. A flagged chat shows a notice with Reset: after a confirmation it drops the local MLS state without publishing (a SelfRemove at a dead epoch decrypts for no one), keeps the decrypted history on disk, and republishes a KeyPackage if needed. The composer then asks the user to have an admin remove and re-add them. The next Welcome joins normally because the group is no longer held locally. An external join isn't possible, since nobody publishes a GroupInfo. The inbound error log now names its group. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 15 +++ .../ui/screen/loggedIn/AccountViewModel.kt | 2 + .../loggedIn/DecryptAndIndexProcessor.kt | 12 +- .../chats/marmotGroup/MarmotGroupChatView.kt | 66 ++++++++++- .../commons/marmot/MarmotDesyncDetector.kt | 103 ++++++++++++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 61 +++++++++-- .../model/marmotGroups/MarmotGroupChatroom.kt | 6 + .../marmot/MarmotDesyncDetectorTest.kt | 85 +++++++++++++++ .../commons/marmot/MarmotDisbandTest.kt | 33 ++++++ .../composeResources/values/strings.xml | 5 + 10 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index dbc7084b4f..3ba76a367c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -605,6 +605,21 @@ class AccountMarmotActions( lastOwnerCheck = null } + /** + * Drop this device's copy of a group it has fallen out of sync with, and make sure a + * fresh KeyPackage is out there for the admin's re-invite. See + * [MarmotManager.resetOutOfSyncGroup]. + */ + suspend fun resetOutOfSyncMarmotGroup(nostrGroupId: HexKey) { + val manager = account.marmotManager ?: return + manager.resetOutOfSyncGroup(nostrGroupId) + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + chatroom.isOutOfSync.value = false + chatroom.awaitingReinvite.value = true + account.marmotGroupList.notifyGroupChanged(nostrGroupId) + ensureMarmotKeyPackagePublished() + } + /** * Ensure the local user has at least one active KeyPackage bundle and * a published KeyPackage event on relays. Called from [init] after diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f49fcd97f4..d8bcd839b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2556,6 +2556,8 @@ class AccountViewModel( /** True when this account has somewhere to upload a group's encrypted media. */ fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty() + suspend fun resetOutOfSyncMarmotGroup(nostrGroupId: String) = account.marmot.resetOutOfSyncMarmotGroup(nostrGroupId) + suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) { account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index af8f947214..36d274f59c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -421,6 +421,7 @@ private suspend fun processMarmotWelcomeFlow( // Sync MIP-01 metadata from group extensions to chatroom val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + chatroom.awaitingReinvite.value = false manager.syncMetadataTo(result.nostrGroupId, chatroom) Log.d("MarmotDbg") { "processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " + @@ -690,6 +691,10 @@ class GroupEventHandler( when (result) { is GroupEventResult.ApplicationMessage -> { + // A message that decrypts means this device is back in step. + account.marmotGroupList + .getOrCreateGroup(result.groupId) + .isOutOfSync.value = false // Parse the inner event JSON and index it val innerEvent = Event.fromJson(result.innerEventJson) Log.d("MarmotDbg") { @@ -896,7 +901,12 @@ class GroupEventHandler( } is GroupEventResult.Error -> { - Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" } + Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR group=${result.groupId?.take(8)} ${result.message}" } + result.groupId?.let { groupId -> + val outOfSync = manager.isOutOfSync(groupId) + val chatroom = account.marmotGroupList.getOrCreateGroup(groupId) + if (chatroom.isOutOfSync.value != outOfSync) chatroom.isOutOfSync.value = outOfSync + } } } } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 399c647b78..53ad82a1a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -28,8 +28,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -53,11 +55,17 @@ import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_removed import com.vitorpamplona.amethyst.commons.resources.marmot_group_default_name import com.vitorpamplona.amethyst.commons.resources.marmot_not_a_member +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_body +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.loadStringRes @@ -189,10 +197,17 @@ fun MarmotGroupChatView( // stays readable either way, which is the point of a gate that is not // a terminal state. val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle() + val isOutOfSync by chatroom.isOutOfSync.collectAsStateWithLifecycle() + val awaitingReinvite by chatroom.awaitingReinvite.collectAsStateWithLifecycle() val gate = outboundGate - if (gate != null) { + if (awaitingReinvite) { + MarmotGroupNoticeRow(stringRes(Res.string.marmot_awaiting_reinvite)) + } else if (gate != null) { MarmotGroupClosedComposer(gate) } else { + if (isOutOfSync) { + MarmotOutOfSyncBanner(nostrGroupId, accountViewModel) + } MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, newMessageModel = newMessageModel, @@ -391,6 +406,50 @@ private fun MarmotGroupFileUploadDialog( ) } +/** + * This device has fallen off the group's epoch chain (MarmotDesyncDetector): nothing the + * other members send decrypts here, and it will not heal on its own. Offers the one way + * back that exists, dropping the local copy so an admin can add this member again. + */ +@Composable +private fun MarmotOutOfSyncBanner( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, +) { + val scope = rememberCoroutineScope() + var confirming by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { + Text( + text = stringRes(Res.string.marmot_out_of_sync_body), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = { confirming = true }, modifier = Modifier.align(Alignment.End)) { + Text(stringRes(Res.string.marmot_out_of_sync_reset)) + } + } + + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_title)) }, + text = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + confirming = false + scope.launch(Dispatchers.IO) { accountViewModel.resetOutOfSyncMarmotGroup(nostrGroupId) } + }, + ) { Text(stringRes(Res.string.marmot_out_of_sync_reset)) } + }, + dismissButton = { + TextButton(onClick = { confirming = false }) { Text(stringRes(Res.string.cancel)) } + }, + ) + } +} + /** * Stands in for the composer when an outbound gate is up. * @@ -407,6 +466,11 @@ private fun MarmotGroupClosedComposer(gate: LocalOutboundGate) { LocalOutboundGate.LEAVING -> stringRes(Res.string.marmot_group_composer_leaving) LocalOutboundGate.REMOVED -> stringRes(Res.string.marmot_group_composer_removed) } + MarmotGroupNoticeRow(message) +} + +@Composable +private fun MarmotGroupNoticeRow(message: String) { Row( modifier = EditFieldModifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt new file mode 100644 index 0000000000..4806ba5c0f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Notices when this device has fallen off a group's epoch chain: the other members + * keep talking and none of it decrypts here. + * + * A fork like that does not heal on its own. The members who moved on hold no copy of + * the epoch this device is stuck at, and this device cannot apply their commits, so + * every message they send fails with "decrypts on no canonical epoch". Groups broken + * this way before the own-commit echo fix never recovered, and nothing told the user. + * + * The same error is also routine: an event from BEFORE this device joined, or older + * than the retained epochs, fails exactly like that. So only failures NEWER than the + * last event this group decrypted here count, a single burst does not trip it (they + * must span [minSpanSec] of sender time), and one successful decrypt clears it. A + * group with no baseline (never decrypted anything here and not seeded) is never + * flagged: there is nothing to be behind. + */ +class MarmotDesyncDetector( + private val threshold: Int = 3, + private val minSpanSec: Long = 60, +) { + private class Tracker { + var lastSuccessAt: Long = 0 + val failures = mutableMapOf() + var desynced = false + } + + private val lock = KmpLock() + private val groups = mutableMapOf() + + /** Baseline for a group restored or joined with no decrypt yet this session. */ + fun seed( + groupId: HexKey, + createdAt: Long, + ) = lock.withLock { + val t = groups.getOrPut(groupId) { Tracker() } + if (createdAt > t.lastSuccessAt) t.lastSuccessAt = createdAt + } + + /** Something at [createdAt] decrypted: the group is on the chain. Returns true if that cleared a desync. */ + fun onDecrypted( + groupId: HexKey, + createdAt: Long, + ): Boolean = + lock.withLock { + val t = groups.getOrPut(groupId) { Tracker() } + if (createdAt > t.lastSuccessAt) t.lastSuccessAt = createdAt + t.failures.clear() + val was = t.desynced + t.desynced = false + was + } + + /** An app message that decrypts on no epoch here. Returns true when this call newly flags the group. */ + fun onUndecryptable( + groupId: HexKey, + eventId: HexKey, + createdAt: Long, + ): Boolean = + lock.withLock { + val t = groups[groupId] ?: return@withLock false + if (t.lastSuccessAt == 0L || createdAt <= t.lastSuccessAt || t.desynced) return@withLock false + t.failures[eventId] = createdAt + val span = t.failures.values.max() - t.failures.values.min() + if (t.failures.size >= threshold && span >= minSpanSec) { + t.desynced = true + true + } else { + false + } + } + + fun isDesynced(groupId: HexKey): Boolean = lock.withLock { groups[groupId]?.desynced == true } + + fun forget(groupId: HexKey) { + lock.withLock { groups.remove(groupId) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index da70cac22b..c6704b525d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -178,6 +178,8 @@ class MarmotManager( * Restore all Marmot state from persistent storage. * Call once during Account initialization. */ + private val desync = MarmotDesyncDetector() + suspend fun restoreAll() { Log.d("MarmotManager") { "restoreAll(): begin for ${signer.pubKey.take(8)}…" } try { @@ -187,8 +189,13 @@ class MarmotManager( // syncWithGroupManager fills in default (since = null) entries, // so even the first filter set sent to relays skips the // already-processed kind:445 backlog. - subscriptionSinceFromStoredMessages(activeIds).forEach { (groupId, since) -> - subscriptionManager.subscribeGroup(groupId, since) + newestStoredMessageTimes(activeIds).forEach { (groupId, newest) -> + // The newest event this group decrypted before the restart is where "behind" + // starts counting (see MarmotDesyncDetector). + desync.seed(groupId, newest) + if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) { + subscriptionManager.subscribeGroup(groupId, newest - GROUP_EVENT_REFETCH_OVERLAP_SEC) + } } subscriptionManager.syncWithGroupManager(activeIds) // Seed convergence with each restored state. A commit that arrives @@ -392,7 +399,7 @@ class MarmotManager( * are still fetched; replays inside the window are deduplicated by the * message store and by note identity in the chatroom. */ - private suspend fun subscriptionSinceFromStoredMessages(groupIds: Set): Map { + private suspend fun newestStoredMessageTimes(groupIds: Set): Map { if (messageStore == null) return emptyMap() val result = mutableMapOf() for (groupId in groupIds) { @@ -409,10 +416,7 @@ class MarmotManager( // and a single future-dated message must not push `since` past // the present — that would skip genuinely new events on every // restart until a fresher message arrives. - val newest = minOf(newestStored, TimeUtils.now()) - if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) { - result[groupId] = newest - GROUP_EVENT_REFETCH_OVERLAP_SEC - } + result[groupId] = minOf(newestStored, TimeUtils.now()) } return result } @@ -443,14 +447,17 @@ class MarmotManager( when (result) { is GroupEventResult.ApplicationMessage -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.CommitProcessed -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.ProposalStaged -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.CommitPending, @@ -458,13 +465,43 @@ class MarmotManager( is GroupEventResult.UndecryptableOuterLayer, is GroupEventResult.AppMessageOnCandidateBranch, is GroupEventResult.RefusedByLifecycle, - is GroupEventResult.Error, -> {} + + is GroupEventResult.Error -> { + val groupId = result.groupId + if (groupId != null && result.message.startsWith(NO_CANONICAL_EPOCH_ERROR)) { + if (desync.onUndecryptable(groupId, groupEvent.id, groupEvent.createdAt)) { + Log.w("MarmotManager") { "group ${groupId.take(8)}… is out of sync: newer peer messages decrypt on no epoch here" } + } + } + } } return result } + /** Whether the other members of [nostrGroupId] have moved on to epochs this device can't follow. */ + fun isOutOfSync(nostrGroupId: HexKey): Boolean = desync.isDesynced(nostrGroupId) + + /** + * Drop this device's copy of a group it has fallen out of sync with, so it can be + * invited back. + * + * A fork cannot be repaired from this side: the peers hold no copy of our epoch and + * we cannot apply theirs, and an external join needs a GroupInfo nobody publishes. + * What does work is the ordinary invite path. With the MLS state gone, a new Welcome + * is not "already a member" and joins; an admin removes this member and adds it back. + * Nothing is published: a SelfRemove at our stale epoch would decrypt for no one. + * The decrypted history stays on disk, so it is back when the group is. + */ + suspend fun resetOutOfSyncGroup(nostrGroupId: HexKey) { + subscriptionManager.unsubscribeGroup(nostrGroupId) + publishGate.forget(nostrGroupId) + groupManager.removeGroupState(nostrGroupId) + desync.forget(nostrGroupId) + Log.w("MarmotManager") { "reset out-of-sync group ${nostrGroupId.take(8)}…; waiting for a new Welcome" } + } + /** * Process a WelcomeEvent (kind:444) after NIP-59 unwrapping. * Returns the result including whether KeyPackage rotation is needed. @@ -479,6 +516,8 @@ class MarmotManager( val result = inboundProcessor.processWelcome(welcomeEvent, hintNostrGroupId) if (result is WelcomeResult.Joined) { + // Joined now: only what is sent from here on has to decrypt. + desync.seed(result.nostrGroupId, TimeUtils.now()) // An authenticated re-join is what clears a departure gate — the // rule `LocalOutboundGate.REMOVED` states, and `LEAVING` needs it // just as much: a member who left and was invited back holds a gate @@ -1072,6 +1111,7 @@ class MarmotManager( publishGate.satisfyEmptyObligation(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId) + desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" } return nostrGroupId @@ -1114,6 +1154,7 @@ class MarmotManager( publishGate.satisfyEmptyObligation(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId) + desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) return nostrGroupId } @@ -2685,6 +2726,7 @@ class MarmotManager( // events the UI never sees directly — a disband request resolving, a // removal being realized. chatroom.outboundGate.value = publishGate.outboundGateNow(nostrGroupId) + chatroom.isOutOfSync.value = desync.isDesynced(nostrGroupId) // A group we created is ours: keep it out of "New Requests" across restarts. // `markAsKnown` at creation is in-memory only, and a creator who has not posted // yet has nothing else that says so. The creator holds leaf 0 (RFC 9420 adds a @@ -2711,6 +2753,9 @@ class MarmotManager( */ internal val GROUP_EVENT_REFETCH_OVERLAP_SEC: Long = TimeUtils.ONE_DAY.toLong() + /** Prefix of the inbound error for an app message no epoch here can open. */ + internal const val NO_CANONICAL_EPOCH_ERROR = "Application message decrypts on no canonical epoch" + /** * How often the settler re-checks an open pass. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 05aa779b29..c95d460266 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -90,6 +90,12 @@ class MarmotGroupChatroom( */ var isCurrentProfile = MutableStateFlow(true) + /** This device can no longer read what the other members send (see MarmotDesyncDetector). */ + val isOutOfSync = MutableStateFlow(false) + + /** This device dropped its copy of the group and waits for an admin to add it back. */ + val awaitingReinvite = MutableStateFlow(false) + /** * True once the group carries the `encrypted-media-v2` policy (`0x800b`). * diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt new file mode 100644 index 0000000000..c2797f5491 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MarmotDesyncDetectorTest { + private val g = "a".repeat(64) + + private fun id(n: Int) = n.toString().padStart(64, '0') + + @Test + fun `peer messages newer than our last decrypt that keep failing flag the group`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + assertFalse(d.onUndecryptable(g, id(1), 1_010)) + assertFalse(d.onUndecryptable(g, id(2), 1_030)) + assertTrue(d.onUndecryptable(g, id(3), 1_080)) + assertTrue(d.isDesynced(g)) + } + + @Test + fun `history from before our last decrypt never counts`() { + // Old epochs (before we joined, or past retention) fail the same way. + val d = MarmotDesyncDetector() + d.seed(g, 5_000) + (1..20).forEach { assertFalse(d.onUndecryptable(g, id(it), 1_000L + it * 100)) } + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a burst from one moment is not enough`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + (1..10).forEach { assertFalse(d.onUndecryptable(g, id(it), 1_010L + it)) } + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a replayed event counts once`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + repeat(5) { d.onUndecryptable(g, id(1), 1_010) } + d.onUndecryptable(g, id(2), 1_100) + assertFalse(d.isDesynced(g)) + } + + @Test + fun `one decrypt clears the flag`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + d.onUndecryptable(g, id(1), 1_010) + d.onUndecryptable(g, id(2), 1_040) + d.onUndecryptable(g, id(3), 1_090) + assertTrue(d.onDecrypted(g, 1_100)) + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a group with no baseline is never flagged`() { + val d = MarmotDesyncDetector() + (1..5).forEach { d.onUndecryptable(g, id(it), 1_000L + it * 100) } + assertFalse(d.isDesynced(g)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt index 20532128d5..4bd030c460 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState @@ -282,6 +283,38 @@ class MarmotDisbandTest { assertTrue(!bobsRoom.isKnown(emptySet()), "an invitation from someone we don't follow") } + @Test + fun `a member reset after falling out of sync is back in once re-added`() = + runBlocking { + // The recovery for a device stuck on a dead epoch: it drops its copy without + // publishing anything, an admin removes and re-adds it, and the new Welcome + // joins instead of being taken for a replay of a group it still holds. + val alice = Fixture() + val bob = Fixture() + alice.createCurrentProfile() + val kp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList()) + bob.manager.ingest(welcome!!.giftWrapEvent) + + bob.manager.resetOutOfSyncGroup(nostrGroupId) + assertNull(bob.manager.groupState(nostrGroupId), "the stale copy is gone") + + val bobLeaf = + alice.manager + .memberPubkeys(nostrGroupId) + .first { it.pubkey == bob.signer.pubKey } + .leafIndex + alice.manager.removeMember(nostrGroupId, bobLeaf) + val freshKp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, reinvite) = alice.manager.addMember(nostrGroupId, freshKp, emptyList()) + val joined = bob.manager.ingest(reinvite!!.giftWrapEvent) + assertTrue(joined is MarmotIngestResult.JoinedGroup, "re-invite joins, got $joined") + + val hello = alice.manager.buildTextMessage(nostrGroupId, "welcome back") + val received = bob.manager.processGroupEvent(hello.outbound.signedEvent) + assertTrue(received is GroupEventResult.ApplicationMessage, "and reads the group again, got $received") + } + @Test fun `rejoining a group we left clears the departure gate`() = runBlocking { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 50aec281b5..09f612ceb6 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -4172,6 +4172,11 @@ Admin privileges granted Admin privileges revoked Change photo + Messages from the other members can't be read on this device anymore. Reset your copy of the group, then ask an admin to remove you and add you back. + Reset + Reset this group? + This device stops being a member until an admin adds you back. The messages you already have stay here. + Waiting to be added back. Ask an admin to remove you and add you again. Add a Blossom media server in Settings first — the group needs somewhere to upload attachments to. This group now uses encrypted attachments Failed to add %1$s: %2$s From 67d24fad2ab23c4ab80095904406235d1a79556c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 22:16:55 -0400 Subject: [PATCH 20/37] fix(marmot): commit a member's leave when we administer the group Found testing leave against White Noise: a White Noise member left a group whose admin was this account, and nothing happened. Leaving is a SelfRemove proposal that only an admin can commit; MarmotManager.commitPendingProposals exists for that, but neither the app nor amy ever called it. So the proposal sat in the pool, the leaver stayed in the tree (and could still decrypt the group), and both apps kept listing them. MarmotManager.commitStagedProposalsIfAdmin commits staged proposals when this account is an admin. The app runs it on every ProposalStaged, then refreshes the roster; amy's sync runs it for every group that staged one. A concurrent commit by another admin is an ordinary fork for convergence to settle. Co-Authored-By: Claude Opus 5.5 --- .../loggedIn/DecryptAndIndexProcessor.kt | 8 +++++ .../amethyst/commons/marmot/MarmotManager.kt | 24 +++++++++++++++ .../commons/marmot/MarmotSyncPolicy.kt | 7 +++++ .../commons/marmot/MarmotLeaveProposalTest.kt | 29 +++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 36d274f59c..b22771664f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -875,6 +875,14 @@ class GroupEventHandler( Log.d("MarmotDbg") { "GroupEventHandler.add: ProposalStaged group=${result.groupId.take(8)}… senderLeaf=${result.senderLeafIndex}" } + // A departure only takes effect once an admin commits it; if that is us, do it. + val groupId = result.groupId + account.scope.launch(Dispatchers.IO) { + if (manager.commitStagedProposalsIfAdmin(groupId) != null) { + manager.syncMetadataTo(groupId, account.marmotGroupList.getOrCreateGroup(groupId)) + account.marmotGroupList.notifyGroupChanged(groupId) + } + } } is GroupEventResult.AppMessageOnCandidateBranch -> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index c6704b525d..5796ed9978 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -2064,6 +2064,30 @@ class MarmotManager( }.event } + /** + * Commit a staged departure when this account may: the step [commitPendingProposals] + * describes, driven from ingest. + * + * Nothing called it, so when a White Noise member left a group whose only admin was + * this account, the SelfRemove sat in the pool forever: the leaver stayed in the tree + * (still able to decrypt the group), and both apps kept listing them. Only admins + * commit here, the authority MIP-03 gives; another admin committing the same + * proposal at the same time is an ordinary fork that convergence settles. A failure + * is logged, not thrown: it is follow-up work, and the proposal stays staged for the + * next attempt. + */ + suspend fun commitStagedProposalsIfAdmin(nostrGroupId: HexKey): OutboundGroupEvent? { + val view = groupView(nostrGroupId) ?: return null + if (signer.pubKey !in view.adminPubkeys) return null + return try { + commitPendingProposals(nostrGroupId) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("MarmotManager") { "could not commit staged proposals for ${nostrGroupId.take(8)}…: ${e.message}" } + null + } + } + /** * Disband the group: write `marmot.group.lifecycle.v1` (`0x800c`) as * `disbanded` in a Commit every member replays. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt index c8cb6f5726..99bafa11b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt @@ -149,6 +149,7 @@ class MarmotSyncPolicy( val maxGroupSeen = perGroupFilters.keys.associateWith { cursors.groupSince(it) ?: 0L }.toMutableMap() var sawGiftWrap = false val sawGroupEvent = mutableSetOf() + val stagedProposals = mutableSetOf() for ((relay, event) in events) { // All the MLS/NIP-59 decryption + persistence lives in MarmotIngest — @@ -161,6 +162,7 @@ class MarmotSyncPolicy( else -> "" } log("ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}$detail") + if (result is MarmotIngestResult.ProposalStaged) stagedProposals.add(result.groupId) when (event.kind) { GiftWrapEvent.KIND -> { @@ -177,6 +179,11 @@ class MarmotSyncPolicy( } } + // A member's SelfRemove only takes effect once an admin commits it. + for (gid in stagedProposals) { + marmot.commitStagedProposalsIfAdmin(gid)?.let { log("committed staged proposals for ${gid.take(8)} → ${it.signedEvent.id.take(8)}") } + } + if (sawGiftWrap && maxGwSeen > 0) { cursors.giftWrapSince = maxGwSeen } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt index cbd5b71043..493648b9ea 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt @@ -105,6 +105,35 @@ class MarmotLeaveProposalTest { ) } + @Test + fun `after ingest an admin commits a staged departure and a non-admin does not`() = + runBlocking { + // The app and amy call commitStagedProposalsIfAdmin on every ProposalStaged. + // Before that nothing did, and a member who left a group we administer never left. + val alice = Fixture() + val bob = Fixture() + val carol = Fixture() + alice.manager.createCurrentProfileGroup( + nostrGroupId = nostrGroupId, + relays = listOf("wss://relay.invalid"), + profile = GroupProfileV1("departures", ""), + ) + val (_, bobWelcome) = alice.manager.addMember(nostrGroupId, bob.manager.generateKeyPackageEvent(relays = emptyList()), emptyList()) + bob.manager.ingest(bobWelcome!!.giftWrapEvent) + val (addCarol, carolWelcome) = alice.manager.addMember(nostrGroupId, carol.manager.generateKeyPackageEvent(relays = emptyList()), emptyList()) + bob.manager.ingest(addCarol!!.signedEvent) + carol.manager.ingest(carolWelcome!!.giftWrapEvent) + + val proposal = carol.manager.leaveGroup(nostrGroupId) + assertIs(bob.manager.ingest(proposal.signedEvent)) + assertIs(alice.manager.ingest(proposal.signedEvent)) + + assertNull(bob.manager.commitStagedProposalsIfAdmin(nostrGroupId), "bob is not an admin") + assertNotNull(alice.manager.commitStagedProposalsIfAdmin(nostrGroupId), "alice is") + assertEquals(2, alice.manager.memberCount(nostrGroupId)) + assertNull(alice.manager.commitStagedProposalsIfAdmin(nostrGroupId), "nothing left to commit") + } + @Test fun `every member applies the commit that evicts the leaver, not just the committer`() = runBlocking { From a3c5015ed057673908c0f607349405994822c825 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 22:26:10 -0400 Subject: [PATCH 21/37] test(marmot): harness upkeep: MDK 03b1809e, runs on macOS as-is, tests 32-33 - Pin MDK to 03b1809e, the snapshot White Noise Android ships (its MARMOT_VERSION lockfile). White Noise iOS is on the 0.10.4 release (fcc85edd), an ancestor, so per the harness rule the newer one wins. - macOS: the harnesses check for the 127.0.0.2 lo0 alias and print the sudo command instead of failing deep in the relay log. They move wnd sockets to a short /private/tmp dir when the checkout path is too long (wnd binds inside a staging dir that adds ~30 bytes, so an 85-byte path already broke SUN_LEN, and wnd refuses the /tmp symlink alias). They also rebuild amy incrementally on every run, because a stale install/amy produced fake publish failures. --no-build keeps the old behaviour with a warning. - Test 32 (amy's message after a wn commit reaches wn), previously only on a local branch. - Test 33: a wn member leaves a group amy administers and amy commits the departure. It fails without the SelfRemove commit fix earlier in this branch. - README: macOS setup, the MDK pin, and the stale 'cd tools/marmot-interop'. Run on macOS with no manual workarounds: 28 pass, 2 skip (no QUIC broker), and 12, 27 and 29 fail (addressed separately). Co-Authored-By: Claude Opus 5.5 --- cli/tests/README.md | 24 +++++++- cli/tests/headless/helpers.sh | 25 +++++++++ cli/tests/marmot/marmot-interop-headless.sh | 4 ++ cli/tests/marmot/marmot-interop.sh | 2 + cli/tests/marmot/setup.sh | 28 ++++++---- cli/tests/marmot/tests-manage.sh | 61 +++++++++++++++++++++ 6 files changed, 132 insertions(+), 12 deletions(-) diff --git a/cli/tests/README.md b/cli/tests/README.md index c91ed90712..bf8151a34c 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -170,6 +170,12 @@ the Marmot protocol, via its `wn` / `wnd` binaries (the `wn-cli` package). Every test records a pass/fail/skip result into a tab-separated log, and the summary is printed at the end of the run. +The MDK checkout is pinned (`MDK_PIN` in `marmot/setup.sh`) to the commit the +shipping White Noise apps embed, read from their `MARMOT_VERSION` lockfiles; +when Android and iOS disagree the newer one wins. As of 2026-09-27 that is +`03b1809e` (White Noise Android; iOS is on the 0.10.4 release `fcc85edd`, an +ancestor). Override with `MDK_PIN=` to test another MDK. + > These harnesses previously targeted `marmot-protocol/whitenoise-rs`, which was > archived on 2026-08-05 pinned to `mdk-core 0.8.0`. Testing against it meant > testing against a frozen MIP-era client. The reference moved into `mdk`, and @@ -214,6 +220,22 @@ would make strict-mode DM sends spuriously fail. `127.0.0.2` is still pure loopback and isn't matched by that filter. Override with `--host 127.0.0.5` etc. if `127.0.0.2` is taken. +**macOS:** Linux answers every `127.0.0.0/8` address on `lo`; macOS only +answers `127.0.0.1` until you add an alias, once per boot: + +```bash +sudo ifconfig lo0 alias 127.0.0.2 up +``` + +The harnesses check for it and stop with that command instead of failing +later with "Can't assign requested address". Two more macOS traps are handled +for you: `wnd` sockets are moved to a short `/tmp/wnd.*` directory when the +checkout path is too long for `sun_path` ("path must be shorter than +SUN_LEN"), and `amy` is rebuilt incrementally on every run (a stale +`cli/build/install/amy` from another branch produces misleading publish +failures), unless you pass `--no-build`. A run killed mid-way can leave +`amy serve` holding the relay port; `pkill -f "amy.*serve"` frees it. + **Note:** dm-05 validates the kind:15 wire format via reference mode (caller supplies the URL + AES-GCM key/nonce). The upload-mode variant (`dm send-file --file PATH --server URL`) needs a local Blossom server @@ -240,7 +262,7 @@ On the Android side: ## Quick start ```bash -cd tools/marmot-interop +cd cli/tests/marmot ./marmot-interop.sh ``` diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index ea45199223..1a9b97e3e1 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -66,6 +66,22 @@ assert_eq() { return 1 } +# macOS caps a unix socket path at 104 bytes (sun_path) and wnd refuses a longer +# one ("path must be shorter than SUN_LEN"). wnd binds first inside a staging +# dir next to the final path (`.sock../`, ~30 bytes more), so a +# checkout under an ordinary home dir already crosses it: ~85 bytes failed. Move +# such a socket into a short private temp dir (0700, since wnd also refuses a +# socket dir others can read). +short_socket_path() { + local path="$1" + if [[ ${#path} -lt 70 ]]; then printf '%s' "$path"; return; fi + # Resolved, not /tmp itself: on macOS /tmp is a symlink to /private/tmp and wnd + # refuses a socket path through an alias ("untrusted directory alias"). + local dir + dir="$(mktemp -d "$(cd /tmp && pwd -P)/wnd.XXXXXX")" && chmod 700 "$dir" + printf '%s/%s.sock' "$dir" "$(basename "$(dirname "$path")")" +} + # --- embedded relay (amy serve → geode) -------------------------------------- # Every relay-backed harness talks to ONE loopback relay, and that relay is # `amy serve` — i.e. geode, the relay this repo ships — booted from the amy @@ -97,6 +113,15 @@ start_local_relay() { local bind="${RELAY_BIND:-$RELAY_HOST}" mkdir -p "$relay_home" "$RELAY_DATA/logs" + # Linux answers all of 127.0.0.0/8 on lo; macOS only 127.0.0.1 until an alias + # is added, and binding to anything else fails with a bare "Can't assign + # requested address" deep in the relay log. + if [[ "$(uname -s)" == "Darwin" && "$bind" == 127.* && "$bind" != "127.0.0.1" ]] \ + && ! ifconfig lo0 2>/dev/null | grep -q "inet $bind "; then + fail_msg "$bind is not on lo0. On macOS add it once per boot: sudo ifconfig lo0 alias $bind up" + exit 1 + fi + [[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; } # Abort early if something else is already bound to the port — failing diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 1218997077..966303d14a 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -140,6 +140,8 @@ source "$TESTS_DIR/lib.sh" source "$SCRIPT_DIR/setup.sh" # shellcheck source=../headless/helpers.sh source "$TESTS_DIR/headless/helpers.sh" +B_SOCKET="$(short_socket_path "$B_SOCKET")" +C_SOCKET="$(short_socket_path "$C_SOCKET")" # shellcheck source=tests-create.sh source "$SCRIPT_DIR/tests-create.sh" # shellcheck source=tests-manage.sh @@ -212,6 +214,8 @@ ALL_TESTS=( test_29_disband_amy_to_wn test_30_wn_commit_after_app_data_update test_31_reaction_materializes_on_wn + test_32_amy_message_after_wn_commit + test_33_wn_leaves_amy_admin_group ) # --tests runs a subset in the order given. Most tests read state a previous diff --git a/cli/tests/marmot/marmot-interop.sh b/cli/tests/marmot/marmot-interop.sh index d39481c7c3..8003f0fd4f 100755 --- a/cli/tests/marmot/marmot-interop.sh +++ b/cli/tests/marmot/marmot-interop.sh @@ -111,6 +111,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" source "$TESTS_DIR/lib.sh" # shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve) source "$TESTS_DIR/headless/helpers.sh" +B_SOCKET="$(short_socket_path "$B_SOCKET")" +C_SOCKET="$(short_socket_path "$C_SOCKET")" # --- preflight --------------------------------------------------------------- preflight() { diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 3b1ceabafb..8cbdc01d97 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -17,17 +17,22 @@ preflight() { info "$cmd: $(command -v "$cmd")" done - # Build `amy` via gradle if missing. + # Build `amy` via gradle. # # jitpack.io and dl.google.com both return transient 503s on a non-trivial # fraction of cold-cache fetches, and Gradle disables the entire repository # for the rest of the build the moment a single 503 lands — so one bad # roll aborts the whole harness. Retry a few times; each attempt resumes # from Gradle's cache so only the still-missing artifacts get re-fetched. - if [[ ! -x "$AMY_BIN" ]]; then - if [[ "$NO_BUILD" -eq 1 ]]; then - fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 - fi + # + # Build even when the binary exists: an old install/amy from another branch + # tests yesterday's code and fails in misleading ways (a stale amy produced + # "UNIQUE constraint" publish rejections that looked like relay bugs). Gradle + # makes an up-to-date install a no-op. --no-build keeps whatever is there. + if [[ "$NO_BUILD" -eq 1 ]]; then + [[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1; } + warn "--no-build: using the existing $AMY_BIN, which may be older than this checkout" + else local attempt max_attempts=4 for attempt in $(seq 1 $max_attempts); do step "building :cli:installDist (attempt $attempt/$max_attempts)" @@ -68,17 +73,18 @@ preflight() { # what users are running", which is the question the harness exists to answer. # # THE TWO APPS NO LONGER AGREE, and the rule for that is: take the newer. - # As of 2026-09-10 android is on 0.9.21 (`fdd398a8`) and ios is still on - # 0.9.20 (`2f44f6b6`) — android syncs its bindings on its own cadence and got + # As of 2026-09-27 android is on a master snapshot after 0.10.4 + # (`03b1809e`, 2026-09-26) and ios on the 0.10.4 release (`fcc85edd`), which + # is an ancestor of it — android syncs its bindings on its own cadence and got # there first. The newer one is where new validation lands, so it is where - # drift shows up first; a client that satisfies 0.9.21 satisfies 0.9.20, - # since every 0.9.20 rule is still in 0.9.21. Pinning to the laggard would - # test the subset and call it coverage. + # drift shows up first; a client that satisfies the newer MDK satisfies the + # older one. Pinning to the laggard would test the subset and call it + # coverage. # # Bump it deliberately, by reading those lockfiles again — not by drifting. # If they agree again, that is the value; if they disagree, take the newer # and say so here. - MDK_PIN="${MDK_PIN:-fdd398a80f1626f1713787cebe416f7890b5b204}" + MDK_PIN="${MDK_PIN:-03b1809e6387f2d95e566a6a2d2f1212bec4d41b}" if [[ "$(git -C "$WN_REPO" rev-parse HEAD 2>/dev/null)" != "$MDK_PIN" ]]; then if [[ "$NO_BUILD" -eq 1 ]]; then info "mdk is not at the pinned $MDK_PIN and --no-build set — testing whatever is checked out" diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 2d6ea3f396..912eeefbc7 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -337,3 +337,64 @@ test_31_reaction_materializes_on_wn() { printf '%s\n' "$tl" >> "$LOG_FILE" record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)" } + +# The other direction of test 30. After wn commits (a rename carries an +# UpdatePath), both sides reached the same epoch and wn's messages kept +# decrypting on amy, yet wn never showed another amy message. +test_32_amy_message_after_wn_commit() { + banner "Test 32 — amy's message after wn's commit reaches wn" + local id="32 amy after wn commit" + + local out gid mls_gid b_gid + out=$(amy_json marmot group create --name "Interop-32") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy promote failed"; return; } + sleep 5 + wn_b groups rename "$mls_gid" "Interop-32-by-wn" >/dev/null 2>&1 || true + amy_json marmot await rename "$gid" --name "Interop-32-by-wn" --timeout 120 >/dev/null || { record_result "$id" fail "amy did not apply wn's rename"; return; } + + amy_json marmot message send "$gid" "32 from amy after wn commit" >/dev/null || { record_result "$id" fail "amy send failed"; return; } + if wait_for_message B "$mls_gid" "32 from amy after wn commit" 90; then + record_result "$id" pass + else + record_result "$id" fail "wn never received amy's message sent after wn's commit" + fi +} + +test_33_wn_leaves_amy_admin_group() { + banner "Test 33 — wn leaves a group amy administers; amy commits the departure" + local id="33 wn leaves amy's group" + + # Leaving is a SelfRemove proposal that only an admin can commit. Test 15 + # covers a wn admin committing it; here amy is the only admin, so the + # departure takes effect only if amy commits it during sync. + local out gid mls_gid b_gid + out=$(amy_json marmot group create --name "Interop-33") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + wn_b groups leave "$mls_gid" >/dev/null 2>&1 || { record_result "$id" fail "wn leave failed"; return; } + + local deadline=$(( $(date +%s) + 120 )) show b_still=1 + while [[ $(date +%s) -lt $deadline ]]; do + show=$(amy_json marmot group show "$gid" 2>/dev/null) || { sleep 3; continue; } + b_still=$(printf '%s' "$show" | jq --arg p "$B_HEX" '[.members[]? | select((.pubkey // .member_id) == $p)] | length') + [[ "$b_still" == "0" ]] && break + sleep 3 + done + if [[ "$b_still" == "0" ]]; then + record_result "$id" pass + else + record_result "$id" fail "amy never committed wn's SelfRemove; wn is still in the tree" + fi +} From d2ac376dfc92e3d78f003093505f873fb07345c5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 22:56:22 -0400 Subject: [PATCH 22/37] fix(marmot): amy catches up after being offline and honors White Noise admin removals Two of the three harness failures against MDK 0.10.4 were real bugs: Test 12 (offline catch-up). amy's sync applied kind-445 events in the order the relays returned them, newest first. A message or rename sent after a commit failed to open (UndecryptableOuter / 'no canonical epoch') when it was tried before that commit. The cursor then moved past it, so it was never fetched again: an offline member came back missing the rename and every message after a membership change. Sync now handles Welcomes first and group events oldest first, and re-ingests events that failed that way whenever a commit lands in the same pass. Test 12 now passes. Test 27 (deletion wn->amy). White Noise deletes with kind 4891 ('removal', {"v":1,"action":"remove"}) instead of kind 5 whenever the deleter is a group admin, their own messages included. Neither amy nor the app knew the kind, so an admin's deletion from White Noise never applied (the test passed alone and failed after test 21 made wn an admin). A 4891 now retracts its targets when its author is a current group admin: in amy's message list, and in the app live and on restore. It never renders as a row. Test 27 now passes. Test 29 (disband amy->wn) still fails: wn stays at epoch 1 and never applies the disband. Our commit matches MDK's disband validation rules (inline lifecycle + admin-policy updates, every other leaf removed), and a plain removal of the last other member works (new harness test 34 passes), so it points to the post-join window MDK uses for its own rotation. It is left open. Test 29 now logs wn's full group view, and test 27 prints both epochs when it fails. Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/model/Account.kt | 1 + .../amethyst/model/AccountMarmotActions.kt | 15 +++++++ .../loggedIn/DecryptAndIndexProcessor.kt | 1 + .../amethyst/cli/commands/MessageCommands.kt | 9 +++- cli/tests/marmot/marmot-interop-headless.sh | 1 + cli/tests/marmot/tests-manage.sh | 38 +++++++++++++++++ cli/tests/marmot/tests-media.sh | 17 +++++++- .../amethyst/commons/marmot/MarmotManager.kt | 30 ++++++++++++- .../commons/marmot/MarmotSyncPolicy.kt | 36 +++++++++++++++- .../model/marmotGroups/MarmotGroupList.kt | 2 + .../marmot/MarmotEditsAndSystemRowsTest.kt | 42 +++++++++++++++++++ .../foundation/appEvents/MarmotAppEvent.kt | 7 ++++ 12 files changed, 193 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 192e9523c1..eb0d4e75f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4048,6 +4048,7 @@ class Account( // kind:1009 edit is re-linked to its message too. val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note marmotGroupList.addMessage(groupId, innerNote) + marmot.applyMarmotAdminRemoval(groupId, innerEvent) } catch (e: Exception) { Log.w( "Account", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 3ba76a367c..d1b3a20cad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -298,6 +298,21 @@ class AccountMarmotActions( return IndexedInnerEvent(innerNote, isNew) } + /** + * Apply a kind-4891 admin removal: drop the messages it names from the conversation + * when its author is an admin of the group (see [MarmotManager.adminRemovalTargets]). + * Runs on live delivery and on the restart replay, which re-adds every stored message. + */ + fun applyMarmotAdminRemoval( + nostrGroupId: HexKey, + innerEvent: Event, + ) { + val manager = account.marmotManager ?: return + manager.adminRemovalTargets(nostrGroupId, innerEvent).forEach { targetId -> + account.cache.getNoteIfExists(targetId)?.let { account.marmotGroupList.removeMessage(nostrGroupId, it) } + } + } + /** [note] holds the inner event; [isNew] is true the first time this client indexed it. */ class IndexedInnerEvent( val note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index b22771664f..71178e26dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -771,6 +771,7 @@ class GroupEventHandler( // peer-sent kind:1210 is dropped inside addMessage — see // `MarmotGroupList.isDisplayableFeedMessage`. account.marmotGroupList.addMessage(result.groupId, innerNote) + account.marmot.applyMarmotAdminRemoval(result.groupId, innerEvent) // Traffic is the natural clock for disappearing messages: a // group being read is a group whose expired messages should diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt index 8c66e70686..3b56df46df 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -113,7 +113,14 @@ object MessageCommands { // A deletion is not its own row either. The retracted body is // blanked rather than the row dropped, so a harness (or a reader // paging back) can tell "retracted" from "never arrived". - val deleted = ctx.marmot.deletedIds(parsed) + val deleted = + ctx.marmot.deletedIds( + parsed, + ctx.marmot + .groupView(gid) + ?.adminPubkeys + ?.toSet() ?: emptySet(), + ) // Pinned at persist time from the retention of the epoch that // DELIVERED each message, so it is the message's own expiry and not // a recomputation against whatever the group's setting is now. diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 966303d14a..37b233460e 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -216,6 +216,7 @@ ALL_TESTS=( test_31_reaction_materializes_on_wn test_32_amy_message_after_wn_commit test_33_wn_leaves_amy_admin_group + test_34_amy_removes_last_other_member ) # --tests runs a subset in the order given. Most tests read state a previous diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 912eeefbc7..84a653b289 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -398,3 +398,41 @@ test_33_wn_leaves_amy_admin_group() { record_result "$id" fail "amy never committed wn's SelfRemove; wn is still in the tree" fi } + +test_34_amy_removes_last_other_member() { + banner "Test 34 — amy removes the only other member; wn processes its own removal" + local id="34 amy removes wn from a 2-member group" + + # Test 06 removes one of three members. Removing the only other member leaves + # the committer alone in the tree, which is the shape a device removal hit: + # White Noise never applied it and kept showing itself as a member. + local out gid mls_gid b_gid + out=$(amy_json marmot group create --name "Interop-34") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + wn_b messages send "$mls_gid" "34 before removal" >/dev/null 2>&1 || true + amy_json marmot await message "$gid" --match "34 before removal" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got wn's message"; return; } + + amy_json marmot group remove "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy remove failed"; return; } + + local deadline=$(( $(date +%s) + 120 )) gone=0 view + while [[ $(date +%s) -lt $deadline ]]; do + wn_b sync >/dev/null 2>&1 || true + view=$(wn_b_json groups show "$mls_gid" 2>/dev/null || true) + if ! printf '%s' "$view" | jq -e --arg p "$B_HEX" '.result.members[]? | select((.member_id // .pubkey // .public_key) == $p)' >/dev/null 2>&1; then + gone=1; break + fi + sleep 3 + done + printf 'test34 wn view after removal: %s\n' "$(printf '%s' "$view" | head -c 2000)" >>"$LOG_FILE" + if [[ "$gone" -eq 1 ]]; then + record_result "$id" pass + else + record_result "$id" fail "wn still lists itself as a member after amy removed it" + fi +} diff --git a/cli/tests/marmot/tests-media.sh b/cli/tests/marmot/tests-media.sh index 1779cc4ee4..73a285687b 100644 --- a/cli/tests/marmot/tests-media.sh +++ b/cli/tests/marmot/tests-media.sh @@ -439,8 +439,15 @@ test_27_deletion_wn_to_amy() { record_result "$id" fail "amy has no event id for wn's message"; return fi - if ! wn_b messages delete "$mls_gid" "$target" >/dev/null 2>&1; then + # Keep wn's answer: "the command ran" and "the tombstone reached a relay" are + # different, and only the second one can reach amy. + local del + del=$(wn_b --json messages delete "$mls_gid" "$target" 2>>"$LOG_FILE") || { record_result "$id" fail "wn messages delete failed"; return + } + printf 'wn messages delete %s -> %s\n' "$target" "$del" >>"$LOG_FILE" + if ! printf '%s' "$del" | jq -e '.result.published != false' >/dev/null 2>&1; then + record_result "$id" fail "wn did not publish the delete tombstone: $del"; return fi # The row stays, blanked and flagged: "retracted" and "never arrived" are @@ -460,7 +467,12 @@ test_27_deletion_wn_to_amy() { done if [[ "$gone" -ne 1 ]]; then - record_result "$id" fail "amy never marked wn's message deleted"; return + # Tell a lost tombstone from a split group: if the two sides sit on different + # epochs, the delete was sent where amy cannot follow. + local wn_epoch amy_epoch + wn_epoch=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // "?"') + amy_epoch=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // "?"') + record_result "$id" fail "amy never marked wn's message deleted (wn epoch $wn_epoch, amy epoch $amy_epoch)"; return fi if [[ -n "$body" ]]; then record_result "$id" fail "amy flagged the message deleted but still shows '$body'"; return @@ -695,6 +707,7 @@ test_29_disband_amy_to_wn() { wn_b sync >/dev/null 2>&1 || true sleep 5 done + printf 'disband29 wn view: %s\n' "$(wn_b_json groups show "$mls_gid" 2>&1 | head -c 3000)" >>"$LOG_FILE" printf 'disband29 epoch %s -> %s (amy now %s), wn at %s\n' \ "$before_epoch" "$after_epoch" "${amy_now:-?}" "${saw:-}" >>"$LOG_FILE" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 5796ed9978..7c6da904cf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -1598,25 +1598,51 @@ class MarmotManager( * targets together, since a deletion is only authorized against the message * it names. */ - fun deletedIds(messages: List): Set { + fun deletedIds( + messages: List, + /** The group's admins: their kind-4891 removals apply to anyone's message. */ + admins: Set = emptySet(), + ): Set { val authorOf = HashMap(messages.size) val claims = ArrayList>() + val removed = HashSet() for (event in messages) { if (event.kind == DeletionRequestEvent.KIND) { for (tag in event.tags) { if (tag.size >= 2 && tag[0] == "e") claims.add(tag[1] to event.pubKey) } + } else if (event.kind == MarmotAppEvent.KIND_REMOVE) { + if (event.pubKey in admins) removed.addAll(adminRemovalTargets(event)) } else { authorOf[event.id] = event.pubKey } } - val deleted = HashSet(claims.size) + val deleted = HashSet(claims.size + removed.size) for ((targetId, deleter) in claims) { if (authorOf[targetId] == deleter) deleted.add(targetId) } + deleted.addAll(removed.filter { it in authorOf }) return deleted } + /** + * The messages a kind-4891 removal names, if its author may remove them: a current + * admin of [nostrGroupId]. Empty for anything else. White Noise sends 4891 instead of + * kind 5 whenever the deleter is an admin (their own messages included), so without + * this an admin's deletion from White Noise never reached us. + */ + fun adminRemovalTargets( + nostrGroupId: HexKey, + event: Event, + ): List { + if (event.kind != MarmotAppEvent.KIND_REMOVE) return emptyList() + val admins = groupView(nostrGroupId)?.adminPubkeys ?: return emptyList() + if (event.pubKey !in admins) return emptyList() + return adminRemovalTargets(event) + } + + private fun adminRemovalTargets(event: Event): List = event.tags.mapNotNull { tag -> if (tag.size >= 2 && tag[0] == "e") tag[1] else null } + /** * The slice of canonical group state that kind:1210 rows are derived from, * or null when this client is not in the group. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt index 99bafa11b1..3fc71f2066 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt @@ -143,13 +143,24 @@ class MarmotSyncPolicy( } if (filterMap.isEmpty()) return - val events = drain(filterMap, timeoutMs) + // Relays answer newest-first and several relays interleave, but a kind:445 can only + // be opened at the epoch its predecessors built: a message sent after a commit fails + // (UndecryptableOuter, "no canonical epoch") if it is tried before that commit. The + // cursor then moves past it and it is never fetched again, which is how an offline + // member came back missing a rename and every message after a membership change. + // Welcomes first (they create the groups), then group events oldest first. + val events = + drain(filterMap, timeoutMs) + .distinctBy { it.second.id } + .sortedWith(compareBy({ if (it.second.kind == GiftWrapEvent.KIND) 0 else 1 }, { it.second.createdAt })) var maxGwSeen = gwSince ?: 0L val maxGroupSeen = perGroupFilters.keys.associateWith { cursors.groupSince(it) ?: 0L }.toMutableMap() var sawGiftWrap = false val sawGroupEvent = mutableSetOf() val stagedProposals = mutableSetOf() + val waitingOnEpoch = mutableListOf() + var advancedEpoch = false for ((relay, event) in events) { // All the MLS/NIP-59 decryption + persistence lives in MarmotIngest — @@ -163,6 +174,8 @@ class MarmotSyncPolicy( } log("ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}$detail") if (result is MarmotIngestResult.ProposalStaged) stagedProposals.add(result.groupId) + if (event.kind == GroupEvent.KIND && result.couldOpenAfterACommit()) waitingOnEpoch.add(event) + if (result is MarmotIngestResult.Commit) advancedEpoch = true when (event.kind) { GiftWrapEvent.KIND -> { @@ -179,6 +192,22 @@ class MarmotSyncPolicy( } } + // Same-second ties and cross-relay stragglers can still put an event ahead of the + // commit it needs; once a commit landed, give those another go, until a pass + // opens nothing new. + while (advancedEpoch && waitingOnEpoch.isNotEmpty()) { + advancedEpoch = false + val retry = waitingOnEpoch.toList() + waitingOnEpoch.clear() + for (event in retry) { + val result = marmot.ingest(event) + log("retry ${event.kind}/${event.id.take(8)} → ${result::class.simpleName}") + if (result is MarmotIngestResult.Commit) advancedEpoch = true + if (result is MarmotIngestResult.ProposalStaged) stagedProposals.add(result.groupId) + if (result.couldOpenAfterACommit()) waitingOnEpoch.add(event) + } + } + // A member's SelfRemove only takes effect once an admin commits it. for (gid in stagedProposals) { marmot.commitStagedProposalsIfAdmin(gid)?.let { log("committed staged proposals for ${gid.take(8)} → ${it.signedEvent.id.take(8)}") } @@ -224,3 +253,8 @@ class MarmotSyncPolicy( const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60 } } + +/** Failed only because the epoch it was sent at isn't reached yet; a later commit may open it. */ +private fun MarmotIngestResult.couldOpenAfterACommit(): Boolean = + this is MarmotIngestResult.UndecryptableOuter || + (this is MarmotIngestResult.Failure && message.startsWith(MarmotManager.NO_CANONICAL_EPOCH_ERROR)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 2d767a6aa8..59059005c9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -177,6 +177,7 @@ class MarmotGroupList( companion object { private const val MARMOT_INNER_KIND_DELETION = 5 + private const val MARMOT_INNER_KIND_ADMIN_REMOVAL = 4891 private const val MARMOT_INNER_KIND_REACTION = 7 private const val MARMOT_INNER_KIND_EDIT = 1009 private const val MARMOT_INNER_KIND_STREAM_START = 1200 @@ -192,6 +193,7 @@ class MarmotGroupList( private val NON_CHAT_INNER_KINDS = setOf( MARMOT_INNER_KIND_DELETION, + MARMOT_INNER_KIND_ADMIN_REMOVAL, MARMOT_INNER_KIND_REACTION, MARMOT_INNER_KIND_EDIT, MARMOT_INNER_KIND_STREAM_START, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt index e2ea1de62e..0dd1ca3c03 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt @@ -140,6 +140,48 @@ class MarmotEditsAndSystemRowsTest { assertTrue(mine.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents())) } + @Test + fun `an admin's kind 4891 removal retracts a message, a non-admin's does not`() = + runBlocking { + // White Noise sends 4891 instead of kind 5 whenever the deleter is an admin, + // for their own messages too; missing it meant those deletions never applied. + val f = Fixture() + f.manager.createGroup( + nostrGroupId, + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "moderated", + relays = listOf("wss://relay.invalid"), + adminPubkeys = listOf(f.signer.pubKey), + ), + ) + val target = f.manager.buildTextMessage(nostrGroupId, "moderate me") + val admin = f.signer.pubKey + val stranger = "f".repeat(64) + + fun removal(by: String) = + MarmotAppEvent.build( + pubKey = by, + kind = MarmotAppEvent.KIND_REMOVE, + content = """{"v":1,"action":"remove"}""", + createdAt = 1_800_000_000L, + tags = arrayOf(arrayOf("e", target.innerEvent.id)), + ) + val byStranger = removal(stranger) + f.manager.persistDecryptedMessage(nostrGroupId, byStranger.toJson().dropLast(1) + ",\"sig\":\"\"}") + val admins = setOf(admin) + assertTrue(target.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents(), admins)) + assertTrue(f.manager.adminRemovalTargets(nostrGroupId, Event.fromJson(byStranger.toJson().dropLast(1) + ",\"sig\":\"\"}")).isEmpty()) + + val byAdmin = removal(admin) + f.manager.persistDecryptedMessage(nostrGroupId, byAdmin.toJson().dropLast(1) + ",\"sig\":\"\"}") + assertTrue(target.innerEvent.id in f.manager.deletedIds(f.manager.storedEvents(), admins)) + assertEquals( + listOf(target.innerEvent.id), + f.manager.adminRemovalTargets(nostrGroupId, Event.fromJson(byAdmin.toJson().dropLast(1) + ",\"sig\":\"\"}")), + ) + } + @Test fun `one kind 5 retracts every message it names`() = runBlocking { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt index e63c2f7d2f..b23f7f1e65 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt @@ -83,6 +83,13 @@ class MarmotAppEvent( /** A durable group system row, synthesized from canonical state. */ const val KIND_SYSTEM = 1210 + /** + * An admin's removal of a message (anyone's, their own included), with an `e` tag + * naming the target and `{"v":1,"action":"remove"}` as content. White Noise sends + * it instead of a kind-5 deletion whenever the deleter is a group admin. + */ + const val KIND_REMOVE = 4891 + private val ALLOWED_MEMBERS = setOf("id", "pubkey", "created_at", "kind", "tags", "content") val EMPTY_TAGS: TagArray = emptyArray() From ce640e7742197a8685a55d2592473de9673446e1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 09:32:44 -0400 Subject: [PATCH 23/37] fix(mls): send the UpdatePath when a commit leaves us alone in the group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness test 29 (disband amy->wn) failed because wn never applied the disband. Instrumenting MDK showed why: its convergence replay rejects the commit with openmls `InvalidCommit(RequiredPathNotFound)`. RFC 9420 §12.4.1 requires an UpdatePath on any commit carrying a Remove, whatever the tree size. MlsGroup.commit() built the path only when path secrets were derived. When the proposals leave the committer as the only leaf (a disband, or removing the last other member), the direct path is empty, so the commit went out with no path at all. White Noise dropped every such commit: a disbanded group stayed live there, and removing the only other member (the device case where WN kept showing itself as a member) never reached it. The path is now sent whenever it's required; over an empty direct path it carries just the new leaf. The commit secret then matches openmls's derive_path with zero nodes: the starting path secret itself. Nobody else can open that epoch, since the committer is alone. Harness test 34 had passed with the bug: it looked for a member list in `wn groups show`, which has none. It now reads `wn groups members` like test 06 does. It and test 29 fail without this fix and pass with it; a quartz unit test pins the path on a remove-the-last-member commit. Test 29 now saves wn's full group view for diagnosis. Co-Authored-By: Claude Opus 5.5 --- cli/tests/marmot/tests-manage.sh | 8 +++++-- cli/tests/marmot/tests-media.sh | 2 +- .../quartz/mls/group/MlsGroup.kt | 21 +++++++++++++++---- .../quartz/mls/group/MlsGroupNegativeTest.kt | 18 ++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 84a653b289..2517915ce3 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -423,13 +423,17 @@ test_34_amy_removes_last_other_member() { local deadline=$(( $(date +%s) + 120 )) gone=0 view while [[ $(date +%s) -lt $deadline ]]; do wn_b sync >/dev/null 2>&1 || true + # `groups members`, as test 06 reads it: `groups show` carries no member list, and + # reading one there made this pass before wn had processed anything. view=$(wn_b_json groups show "$mls_gid" 2>/dev/null || true) - if ! printf '%s' "$view" | jq -e --arg p "$B_HEX" '.result.members[]? | select((.member_id // .pubkey // .public_key) == $p)' >/dev/null 2>&1; then + if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ + | jq_list members | jq -e --arg p "$B_HEX" \ + 'select((.member_id // .pubkey // .public_key) == $p)' >/dev/null 2>&1; then gone=1; break fi sleep 3 done - printf 'test34 wn view after removal: %s\n' "$(printf '%s' "$view" | head -c 2000)" >>"$LOG_FILE" + printf '%s' "$view" >"$STATE_DIR/test34-wn-view.json" if [[ "$gone" -eq 1 ]]; then record_result "$id" pass else diff --git a/cli/tests/marmot/tests-media.sh b/cli/tests/marmot/tests-media.sh index 73a285687b..620ab6ba86 100644 --- a/cli/tests/marmot/tests-media.sh +++ b/cli/tests/marmot/tests-media.sh @@ -707,7 +707,7 @@ test_29_disband_amy_to_wn() { wn_b sync >/dev/null 2>&1 || true sleep 5 done - printf 'disband29 wn view: %s\n' "$(wn_b_json groups show "$mls_gid" 2>&1 | head -c 3000)" >>"$LOG_FILE" + wn_b_json groups show "$mls_gid" >"$STATE_DIR/disband29-wn-view.json" 2>&1 || true printf 'disband29 epoch %s -> %s (amy now %s), wn at %s\n' \ "$before_epoch" "$after_epoch" "${amy_now:-?}" "${saw:-}" >>"$LOG_FILE" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt index a252731f40..782b3effe4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt @@ -782,8 +782,14 @@ class MlsGroup private constructor( // Without this ordering the committer uses the PRE-commit context and // every strict-validating member derives a different AEAD key, turning // the decryption into `UpdatePathError(UnableToDecrypt)`. + // A required path is sent even when the proposals leave this leaf alone in the tree + // (a disband, or removing the last other member): the direct path is then empty, and + // the UpdatePath carries just the new leaf. RFC 9420 §12.4.1 makes the path mandatory + // for any Remove regardless of the tree's size, and openmls/MDK reject a Remove commit + // without one as `RequiredPathNotFound` — so every such commit we sent was dropped by + // White Noise, and the member we removed kept a live copy of the group. val updatePath: UpdatePath? = - if (needsPath && pathSecrets.isNotEmpty()) { + if (needsPath) { // RFC 9420 §7.9: UpdatePath carries one node per entry in the // **filtered** direct path — parents whose copath subtree has // empty resolution are omitted (encrypting to them is @@ -949,11 +955,18 @@ class MlsGroup private constructor( // else can reach, and every member rejects the commit with a // confirmation-tag mismatch. A SelfRemove-only commit — a departing // member's eviction — is precisely the case that omits the path. + // + // A path over an EMPTY direct path (this leaf alone in the tree) derives no node + // secrets, and openmls's `derive_path` then returns the starting path secret itself + // as the commit secret — zero derivation steps. Nobody else can decrypt that epoch + // anyway; it only has to be what openmls would compute. val commitSecret = - if (updatePath != null && pathSecrets.isNotEmpty()) { - MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path") - } else { + if (updatePath == null) { ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) + } else if (pathSecrets.isEmpty()) { + leafSecret + } else { + MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path") } val newTreeHash = tree.treeHash() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt index 6f41f3911f..5fa7359019 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.mls.framing.MlsMessage import com.vitorpamplona.quartz.mls.framing.PublicMessage import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.Commit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -87,6 +88,23 @@ class MlsGroupNegativeTest { ) } + /** + * Removing the last other member leaves the committer alone, so its direct path is + * empty — but RFC 9420 §12.4.1 still requires an UpdatePath for any Remove. We used to + * omit it, and openmls/MDK dropped the commit (`RequiredPathNotFound`): White Noise never + * learned it had been removed or that the group was disbanded. + */ + @Test + fun removingTheLastOtherMemberStillCarriesAnUpdatePath() { + val fx = twoMemberGroup() + val result = fx.alice.removeMember(1) + val commit = Commit.decodeTls(TlsReader(result.commitBytes)) + val path = commit.updatePath + assertTrue(path != null, "a Remove commit must carry an UpdatePath even with no path nodes") + assertEquals(0, path.nodes.size, "a one-leaf tree has an empty direct path") + assertEquals(1, fx.alice.members().size) + } + /** Baseline: an honest commit applies and advances Bob's epoch. */ @Test fun honestCommitIsAccepted() { From efae63ab60a06cebf77bc76a3c0d60af65b4f749 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:47:18 -0400 Subject: [PATCH 24/37] fix(nip17): a quick delete of a private message no longer publishes a public NIP-09 With "don't show again" set on the delete dialog, the quick-action Delete called the plain delete, which filtered out Marmot messages but not NIP-17 rumors: deleting your own DM signed a public kind-5 e-tagging the private rumor id onto outbox relays. The quick action now uses deleteOwn like the dialog, and Account.delete itself sends any private rumor through deletePrivately, so no caller (the bookmark screen also used the plain delete) can leak one. Co-Authored-By: Claude Opus 5.5 --- .../java/com/vitorpamplona/amethyst/model/Account.kt | 10 +++++++++- .../amethyst/ui/note/NoteQuickActionMenu.kt | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index eb0d4e75f4..b77738a2d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1721,7 +1721,15 @@ class Account( marmot.deleteMarmotMessages(groupId, groupNotes) } - val myNotes = otherNotes.filter { it.author == userProfile() && it.event != null } + val (myRumors, myNotes) = + otherNotes + .filter { it.author == userProfile() && it.event != null } + .partition { it.isPrivateRumor() } + + // Private rumors (NIP-17 DMs, private reactions) are retracted inside their own + // conversation for the same reason, whichever caller asked for a plain delete. + myRumors.forEach { deletePrivately(listOf(it), it) } + if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. myNotes.chunked(200).forEach { chunkedList -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 003b8b5f6a..c0bbf9a613 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -438,7 +438,7 @@ fun CardBody( stringRes(Res.string.quick_action_delete), ) { if (accountViewModel.account.settings.hideDeleteRequestDialog) { - accountViewModel.delete(note) + accountViewModel.deleteOwn(note) onDismiss() } else { showDeleteAlertDialog.value = true From 6c28585229475b7855e7dc94e583848fae3819af Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:47:31 -0400 Subject: [PATCH 25/37] fix(uploads): removing the last attachment no longer crashes the composer The attachment list became snapshot state (so Remove refreshes the gallery), which made removing the only item recompose ImageVideoDescription over an empty orchestrator, whose uris.first() threw. Every composer now drops the orchestrator once it is empty, and the H.265 toggle reads hasVideo() instead of the first item. Checked on the tablet: attach one photo, tap its X, the composer stays up. Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt | 5 ++++- .../com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt | 5 ++++- .../ui/note/creators/uploads/ImageVideoDescription.kt | 2 +- .../amethyst/ui/note/nip22Comments/CommentPostViewModel.kt | 5 ++++- .../ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt | 5 ++++- .../loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt | 5 ++++- .../discover/nip99Classifieds/NewProductViewModel.kt | 5 ++++- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 5 ++++- .../publicMessages/NewPublicMessageViewModel.kt | 5 ++++- 9 files changed, 33 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index cb9aedbd85..2998dc0ce6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -330,6 +330,9 @@ open class EditPostViewModel : ViewModel() { } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 6c58b3d639..c6aa0ea930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -237,7 +237,10 @@ open class NewMediaModel : ViewModel() { } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 20c521b79d..6ad6df6372 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -427,7 +427,7 @@ fun ImageVideoDescription( } } - if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) { + if (uris.hasVideo() && mediaQualitySlider != 3) { SettingSwitchItem( title = Res.string.video_codec_h265_label, description = Res.string.video_codec_h265_description, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 71616b6476..656bcf3afa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -937,7 +937,10 @@ open class CommentPostViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 2c6327533a..ea55a4ee76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -78,7 +78,10 @@ class ChatFileUploadState( } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 4b10bc1c3c..de2ed7b234 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -671,7 +671,10 @@ class LongFormPostViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 9b588113cd..3d9a0f574d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -514,7 +514,10 @@ open class NewProductViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index e4733e4694..fe72a2233d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1753,7 +1753,10 @@ open class ShortNotePostViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 32ae7d4e20..b33f4b1daa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -557,7 +557,10 @@ class NewPublicMessageViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { From 0e1bd6e810a0d9806272d201c0e235b1d4a8165d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:47:33 -0400 Subject: [PATCH 26/37] fix(uploads): sign a Blossom upload once and stop the fallback on a signer failure The server fallback re-ran the whole upload per server, token included: an Amber or NIP-46 user who rejected the prompt, or a bunker that timed out, was asked again for every default server, and each attempt re-hashed the file first. The token is not scoped to a server, so it is now signed once and shared across attempts; any signer failure (not just read-only) ends the fallback, and a signer that returned no token is asked again rather than cached. Attempts no longer publish their own Error, so the badge (and its Remove) no longer flashes between servers. Co-Authored-By: Claude Opus 5.5 --- .../service/uploads/UploadOrchestrator.kt | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 193a275694..bf59f45883 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -205,23 +205,26 @@ class UploadOrchestrator { /** * Tries [selected], then the rest of the servers the picker offered (see - * [blossomUploadOrder]) until one stores the blob. A failure that no other server would - * fix (a read-only login) stops at once. When every server fails, the error shown is the - * selected server's: that is the one the user chose and will recognize. + * [blossomUploadOrder]) until one stores the blob. Every server gets the same blob, so they + * share one signed upload token: the signer is asked once, and a signer that refused, timed + * out or is read-only stops the fallback instead of prompting again per server. Attempts + * don't publish their errors; when every server fails, the error shown is the selected + * server's, the one the user chose and will recognize. */ private suspend fun uploadBlossomWithFallback( selected: ServerName, account: Account, - uploadTo: suspend (serverBaseUrl: String) -> UploadingFinalState, + uploadTo: suspend (serverBaseUrl: String, auth: SharedUploadAuth) -> UploadingFinalState, ): UploadingFinalState { val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value) + val auth = SharedUploadAuth() var firstError: UploadingState.Error? = null for (server in order) { - when (val result = uploadTo(server.baseUrl)) { + when (val result = uploadTo(server.baseUrl, auth)) { is UploadingState.Finished -> return result is UploadingState.Error -> { if (firstError == null) firstError = result - if (result.errorResource == Res.string.login_with_a_private_key_to_be_able_to_upload) return result + if (auth.signerFailed) return result.also { updateState(0.0, it) } Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server") } } @@ -229,6 +232,29 @@ class UploadOrchestrator { return firstError!!.also { updateState(0.0, it) } } + /** + * The upload token for one blob, signed at most once and reused by every server tried. + * A signer that returned no token is asked again on the next server; only a token or a + * signer failure is kept. + */ + private class SharedUploadAuth { + private var outcome: Result? = null + + var signerFailed = false + private set + + suspend fun get(sign: suspend () -> BlossomAuthorizationEvent?): BlossomAuthorizationEvent? { + outcome?.let { return it.getOrThrow() } + return try { + sign()?.also { outcome = Result.success(it) } + } catch (e: SignerExceptions) { + signerFailed = true + outcome = Result.failure(e) + throw e + } + } + } + private suspend fun uploadBlossom( fileUri: Uri, contentType: String?, @@ -241,6 +267,7 @@ class UploadOrchestrator { account: Account, forcedSigner: NostrSigner?, context: Context, + sharedAuth: SharedUploadAuth, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) // BUD-05: route through /media (optimize) when the user opted in. The forced-signer @@ -262,12 +289,15 @@ class UploadOrchestrator { // upload path: some servers reject an upload whose auth carries a // `server` tag, and upload-token replay is not the threat scoping // guards against (delete tokens are — those stay scoped). - httpAuth = - when { - forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } - useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) } - else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) } - }, + httpAuth = { hash, size, alt -> + sharedAuth.get { + when { + forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) + useMedia -> account.createBlossomMediaAuth(hash, size, alt) + else -> account.createBlossomUploadAuth(hash, size, alt) + } + } + }, context = context, useMediaEndpoint = useMedia, ) @@ -298,13 +328,13 @@ class UploadOrchestrator { finalState } catch (_: SignerExceptions.ReadOnlyException) { - error(Res.string.login_with_a_private_key_to_be_able_to_upload) + UploadingState.Error(Res.string.login_with_a_private_key_to_be_able_to_upload, emptyArray()) } catch (e: BlossomPaymentException) { // BUD-07: the server wants payment before it will store the blob. - error(Res.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl) + UploadingState.Error(Res.string.blossom_payment_required, arrayOf(e.payment.reason ?: serverBaseUrl)) } catch (e: Exception) { if (e is CancellationException) throw e - error(Res.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName) + UploadingState.Error(Res.string.failed_to_upload_media, arrayOf(e.message?.ifBlank { null } ?: e.javaClass.simpleName)) } } @@ -510,8 +540,8 @@ class UploadOrchestrator { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) ServerType.Blossom -> - uploadBlossomWithFallback(server, account) { baseUrl -> - uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context) + uploadBlossomWithFallback(server, account) { baseUrl, auth -> + uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth) } } } finally { @@ -560,8 +590,8 @@ class UploadOrchestrator { // The same encrypted file goes to every server tried, so its key, nonce and // hash stay valid whichever one ends up holding it. ServerType.Blossom -> - uploadBlossomWithFallback(server, account) { baseUrl -> - uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + uploadBlossomWithFallback(server, account) { baseUrl, auth -> + uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth) } } } finally { From 3615caabcc185b638ef3762b9f5d59c6d4df6441 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:47:47 -0400 Subject: [PATCH 27/37] fix(marmot): an admin removal that decrypts before its target still removes it A kind-4891 only dropped messages already on screen and was not remembered. A catch-up returns newest first and both sit in one epoch, so the removal often decrypts first, removes nothing, and the target is then added and stays, on every restart too (the replay keeps arrival order). Unlike a kind-5 there is no LocalCache deletion index behind it. MarmotGroupList now keeps the removed ids per group and refuses them on add. Tests cover removal-before-target, the per-group scope, and removal of a shown message. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 2 +- .../model/marmotGroups/MarmotGroupList.kt | 20 ++++++++++++++ .../MarmotGroupFeedVisibilityTest.kt | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index d1b3a20cad..e767ce649d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -309,7 +309,7 @@ class AccountMarmotActions( ) { val manager = account.marmotManager ?: return manager.adminRemovalTargets(nostrGroupId, innerEvent).forEach { targetId -> - account.cache.getNoteIfExists(targetId)?.let { account.marmotGroupList.removeMessage(nostrGroupId, it) } + account.marmotGroupList.applyAdminRemoval(nostrGroupId, targetId, account.cache.getNoteIfExists(targetId)) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 59059005c9..fcd4f2ed18 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -38,6 +38,12 @@ class MarmotGroupList( private val noteToGroupIndex = LargeCache() + // Message id -> group, for every message a group admin removed (kind 4891). A removal can + // decrypt before its target: a catch-up returns newest first, and both sit in one epoch. + // Unlike a kind-5 there is no LocalCache deletion index behind it, so without this record a + // target arriving second would be added and stay, restarts included. + private val adminRemovedIds = LargeCache() + private val _groupListChanges = MutableSharedFlow(0, 20, BufferOverflow.DROP_OLDEST) val groupListChanges = _groupListChanges @@ -48,6 +54,7 @@ class MarmotGroupList( msg: Note, ) { if (!isDisplayableFeedMessage(msg)) return + if (adminRemovedIds.get(msg.idHex) == nostrGroupId) return val chatroom = getOrCreateGroup(nostrGroupId) if (chatroom.addMessageSync(msg)) { noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } @@ -99,6 +106,19 @@ class MarmotGroupList( } } + /** + * Applies an admin removal of [targetId] in [nostrGroupId]: drops [target] if it is already + * shown, and keeps the removal so the message is refused if it arrives later. + */ + fun applyAdminRemoval( + nostrGroupId: HexKey, + targetId: HexKey, + target: Note?, + ) { + adminRemovedIds.put(targetId, nostrGroupId) + if (target != null) removeMessage(nostrGroupId, target) + } + /** * Drop a group from the in-memory list. Also clears the chatroom's own * message set and the note→group index: LocalCache holds notes weakly, so diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt index 57363d7fbc..3ec74e6a8e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt @@ -113,4 +113,30 @@ class MarmotGroupFeedVisibilityTest { // note skipped the kind rules above and rendered "Event is loading or can't be found". assertEquals(0, visibleCount(list(), Note("d".repeat(64)))) } + + @Test + fun `an admin removal that arrives before its target still removes it`() { + // A catch-up returns newest first, so the kind-4891 can decrypt before the message. + val list = list() + val target = note(9, peer, content = "spam") + list.applyAdminRemoval(groupId, target.idHex, null) + assertEquals(0, visibleCount(list, target)) + } + + @Test + fun `an admin removal in one group does not hide the same id in another`() { + val list = list() + val target = note(9, peer, content = "hello") + list.applyAdminRemoval("e".repeat(64), target.idHex, null) + assertEquals(1, visibleCount(list, target)) + } + + @Test + fun `an admin removal drops a message already shown`() { + val list = list() + val target = note(9, peer, content = "spam") + assertEquals(1, visibleCount(list, target)) + list.applyAdminRemoval(groupId, target.idHex, target) + assertEquals(0, list.getOrCreateGroup(groupId).messages.size) + } } From 8d3565c12495c6de60207c3f7048751d2c527096 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:47:48 -0400 Subject: [PATCH 28/37] fix(marmot): a group's preview shows the edited text of its last message The Marmot list row and the Messages-tab row read the last message's original content, so after an edit the bubble showed the new text and the rows kept the old one. The preview now observes the message's edits, the same overlay the bubble draws. Checked on the tablet: editing the newest message updates the Messages row. Co-Authored-By: Claude Opus 5.5 --- .../loggedIn/chats/marmotGroup/MarmotGroupPreview.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt index 2c297319c3..4130dc30d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.observeChatEdit import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex @@ -58,6 +59,10 @@ fun marmotGroupPreviewText( // to render as a blank second line under the group name. val myPubKey = accountViewModel.account.signer.pubKey val previewEvent = newestMessage?.event + // An edited last message previews its new text, as its bubble does. Observed rather than + // read once so an edit arriving while the list is open updates the row. + val editedContent = newestMessage?.let { observeChatEdit(it) }?.event?.content + val shownText = editedContent ?: previewEvent?.content.orEmpty() val previewBody = when { newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet) @@ -66,7 +71,7 @@ fun marmotGroupPreviewText( // Text first: an attachment usually carries a caption, and showing // "Attachment" over the words the sender actually wrote would be a // step back from the raw `content` this replaced. - previewEvent.content.isNotBlank() -> previewEvent.content + shownText.isNotBlank() -> shownText hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media) else -> stringRes(Res.string.marmot_preview_no_text) } From 9528c812121f53fdb2bc34fcdf2f30dd6255e7f1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:47:50 -0400 Subject: [PATCH 29/37] fix(chats): replying while editing a message drops the edit's text reply() left edit mode but kept the edited message's prefilled text, so Send posted it again as a new reply. It now cancels the edit, text included, in Marmot and in Concord (which had the same bug). Checked on the tablet: Edit a message, Reply to another, the composer is empty. Co-Authored-By: Claude Opus 5.5 --- .../chats/marmotGroup/send/MarmotNewMessageViewModel.kt | 4 +++- .../publicChannels/concord/send/ConcordNewMessageViewModel.kt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt index 36e1beb9ad..ac02ee25e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -93,7 +93,9 @@ open class MarmotNewMessageViewModel : ViewModel() { fun reply(note: Note) { replyTo.value = note - editingMessage.value = null + // Leaving edit mode drops the edited message's prefilled text too; kept, Send would + // post it again as a new reply. + if (editingMessage.value != null) cancelEdit() } /** Enter edit mode for my own [note], prefilled with the text it currently shows. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index ecd6dd4667..0361980ecb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -133,7 +133,9 @@ open class ConcordNewMessageViewModel : ViewModel() { fun reply(note: Note) { replyTo.value = note replyMode.value = ReplyMode.INLINE - editingMessage.value = null + // Leaving edit mode drops the edited message's prefilled text too; kept, Send would + // post it again as a new reply. + if (editingMessage.value != null) cancelEdit() } /** Enter edit mode for my own [note]: prefills the field with its current text; sending publishes a kind-1010 edit. */ From 9464879a8025dd5d80f08269de8b0c6cc876b97c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:02 -0400 Subject: [PATCH 30/37] test(marmot): harness checks that could pass without testing anything - Removal checks read "not in the member list" off a pipeline where a failed wn query (dead daemon, ok:false) also produced no match. Tests 06 and 34 now require wn's own positive signal (self_membership "removed"); the leave and avatar checks use wn_lists, which tells "answered, not listed" from "no answer". - setup.sh fell through to the old amy binary when all four builds failed; it now fails. - Test 27's "tombstone published" check compared a count to false, so it never fired; it requires published > 0. - The short wnd socket dir was random per run: dirs piled up in /tmp and start_daemon could not find a wnd a killed run left behind. It is now derived from the path (0700, reused only when ours). Headless run: 32 passed, 0 failed, 2 skipped. Co-Authored-By: Claude Opus 5.5 --- cli/tests/headless/helpers.sh | 17 +++++++++++++++-- cli/tests/lib.sh | 23 +++++++++++++++++++++++ cli/tests/marmot/setup.sh | 12 ++++++++---- cli/tests/marmot/tests-manage.sh | 27 ++++++++++----------------- cli/tests/marmot/tests-media.sh | 2 +- 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index 1a9b97e3e1..124f13e5c2 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -72,13 +72,26 @@ assert_eq() { # checkout under an ordinary home dir already crosses it: ~85 bytes failed. Move # such a socket into a short private temp dir (0700, since wnd also refuses a # socket dir others can read). +# +# The dir is derived from the long path, not random: the same checkout gets the same +# socket every run, so `start_daemon` still finds a wnd a killed run left behind +# instead of starting a second one on the same data, and runs stop piling up dirs. short_socket_path() { local path="$1" if [[ ${#path} -lt 70 ]]; then printf '%s' "$path"; return; fi # Resolved, not /tmp itself: on macOS /tmp is a symlink to /private/tmp and wnd # refuses a socket path through an alias ("untrusted directory alias"). - local dir - dir="$(mktemp -d "$(cd /tmp && pwd -P)/wnd.XXXXXX")" && chmod 700 "$dir" + local tmp dir + tmp="$(cd /tmp && pwd -P)" + dir="$tmp/wnd-$(id -u)-$(printf '%s' "$path" | cksum | cut -d' ' -f1)" + if ! mkdir -m 700 "$dir" 2>/dev/null; then + # Reuse only a dir that is ours and private: /tmp is shared, and a socket in a + # dir someone else controls could be swapped under wnd. + if [[ ! -d "$dir" || -L "$dir" || ! -O "$dir" ]]; then + dir="$(mktemp -d "$tmp/wnd.XXXXXX")" + fi + chmod 700 "$dir" + fi printf '%s/%s.sock' "$dir" "$(basename "$(dirname "$path")")" } diff --git a/cli/tests/lib.sh b/cli/tests/lib.sh index 2bb3f00627..c0f55b5f70 100644 --- a/cli/tests/lib.sh +++ b/cli/tests/lib.sh @@ -199,6 +199,29 @@ jq_list() { ' 2>/dev/null || true } +# Whether 's wn lists in the group's . Three answers, because a +# query that fails is not evidence of absence: piped straight into `jq -e select`, a dead +# daemon or an `ok:false` reply read exactly like "not listed" and passed removal tests. +# 0 listed · 1 wn answered and is not listed · 2 no usable answer +wn_lists() { + local who="$1" gid="$2" list="$3" hex="$4" out + out=$("wn_$who" --json groups members "$gid" 2>/dev/null) || return 2 + printf '%s' "$out" | jq -e '.ok == true' >/dev/null 2>&1 || return 2 + if printf '%s' "$out" | jq_list "$list" | jq -e --arg p "$hex" \ + 'select((.member_id // .admin_id // .pubkey // .public_key) == $p)' >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +# Whether 's wn reports that it was itself removed from the group: MDK's +# `groups show` carries `self_membership: "removed"` once the removal commit applied. +wn_self_removed() { + local who="$1" gid="$2" + "wn_$who" --json groups show "$gid" 2>/dev/null \ + | jq -e '.ok == true and .result.group.self_membership == "removed"' >/dev/null 2>&1 +} + # npub or hex pubkey of one member/admin entry. MDK names the field per # collection: members carry `member_id`, admins carry `admin_id`. jq_member_ids() { diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 8cbdc01d97..163d0a0f89 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -33,15 +33,19 @@ preflight() { [[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1; } warn "--no-build: using the existing $AMY_BIN, which may be older than this checkout" else - local attempt max_attempts=4 + local attempt max_attempts=4 built=0 for attempt in $(seq 1 $max_attempts); do step "building :cli:installDist (attempt $attempt/$max_attempts)" - if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \ - && [[ -x "$AMY_BIN" ]]; then - break + ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" + # gradle's status, not tee's: a failed build must not fall through to the old binary. + if [[ "${PIPESTATUS[0]}" -eq 0 && -x "$AMY_BIN" ]]; then + built=1; break fi [[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying" done + # The binary may still exist from an earlier build; running the suite on it would test + # code this checkout no longer has. + [[ "$built" -eq 1 ]] || { fail_msg "amy build failed after $max_attempts attempts"; exit 1; } fi [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } info "amy: $AMY_BIN" diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 2517915ce3..f201392fa6 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -27,10 +27,7 @@ test_06_member_removal() { # C should no longer see the group on its own member view. local deadline=$(( $(date +%s) + 120 )) removed=0 while [[ $(date +%s) -lt $deadline ]]; do - if ! wn_c --json groups members "$mls_gid" 2>/dev/null \ - | jq_list members | jq -e --arg p "$C_HEX" \ - 'select((.member_id // .pubkey // .public_key) == $p)' \ - >/dev/null 2>&1; then + if wn_self_removed c "$mls_gid"; then removed=1; break fi sleep 3 @@ -180,10 +177,9 @@ test_11_leave_group() { local deadline=$(( $(date +%s) + 120 )) gone=0 while [[ $(date +%s) -lt $deadline ]]; do - if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ - | jq_list admins | jq -e --arg p "$A_HEX" \ - 'select((.admin_id // .pubkey // .public_key) == $p)' \ - >/dev/null 2>&1; then + local rc=0 + wn_lists b "$mls_gid" admins "$A_HEX" || rc=$? + if [[ "$rc" -eq 1 ]]; then gone=1; break fi sleep 3 @@ -222,10 +218,9 @@ test_17_group_image_commit() { # Skip cleanly if A is no longer a member of GROUP_02 (a later test may have removed # A) — this test only makes sense while A can still commit to the group. - if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ - | jq_list members | jq -e --arg p "$A_HEX" \ - 'select((.member_id // .pubkey // .public_key) == $p)' \ - >/dev/null 2>&1; then + local listed=0 + wn_lists b "$mls_gid" members "$A_HEX" || listed=$? + if [[ "$listed" -eq 1 ]]; then record_result "$id" skip "A not in GROUP_02"; return fi @@ -423,12 +418,10 @@ test_34_amy_removes_last_other_member() { local deadline=$(( $(date +%s) + 120 )) gone=0 view while [[ $(date +%s) -lt $deadline ]]; do wn_b sync >/dev/null 2>&1 || true - # `groups members`, as test 06 reads it: `groups show` carries no member list, and - # reading one there made this pass before wn had processed anything. + # A positive signal: wn marks its own copy removed once it applied the commit. Reading + # "B is not in the member list" instead passed whenever the query itself failed. view=$(wn_b_json groups show "$mls_gid" 2>/dev/null || true) - if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ - | jq_list members | jq -e --arg p "$B_HEX" \ - 'select((.member_id // .pubkey // .public_key) == $p)' >/dev/null 2>&1; then + if wn_self_removed b "$mls_gid"; then gone=1; break fi sleep 3 diff --git a/cli/tests/marmot/tests-media.sh b/cli/tests/marmot/tests-media.sh index 620ab6ba86..ad5e9b92c9 100644 --- a/cli/tests/marmot/tests-media.sh +++ b/cli/tests/marmot/tests-media.sh @@ -446,7 +446,7 @@ test_27_deletion_wn_to_amy() { record_result "$id" fail "wn messages delete failed"; return } printf 'wn messages delete %s -> %s\n' "$target" "$del" >>"$LOG_FILE" - if ! printf '%s' "$del" | jq -e '.result.published != false' >/dev/null 2>&1; then + if ! printf '%s' "$del" | jq -e '(.result.published // 0) > 0' >/dev/null 2>&1; then record_result "$id" fail "wn did not publish the delete tombstone: $del"; return fi From 99d9ba0c7655b39906034071cba5026de1f2e14b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:11 -0400 Subject: [PATCH 31/37] fix(marmot): remember that we created a group instead of reading it off leaf 0 Holding leaf 0 was taken to mean "I created this group" (to keep it out of New Requests after a restart). A commit that removes leaf 0 and adds someone in the same epoch places the invitee there, so a crafted invite could skip New Requests. Creation now leaves a durable marker, stored with the ingest markers (hashed, so it cannot collide with an event id) and read back on restore. New test: a fresh manager on the same stores still knows the group is ours. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/commons/marmot/MarmotManager.kt | 31 ++++++++++++++++--- .../commons/marmot/MarmotDisbandTest.kt | 22 +++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 7c6da904cf..5602436d5e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore @@ -212,7 +214,8 @@ class MarmotManager( keyPackageRotationManager.restoreFromStore() ingestDedupStore?.loadAll()?.let { marks -> terminallyIngestedMutex.withLock { terminallyIngested.addAll(marks) } - Unit + val created = activeIds.filter { createdLocallyMarker(it) in marks } + createdLocallyLock.withLock { createdLocally.addAll(created) } } retryPendingPublishObligations() Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" } @@ -229,6 +232,23 @@ class MarmotManager( private val terminallyIngested = mutableSetOf() private val terminallyIngestedMutex = Mutex() + // Groups this device created. Holding leaf 0 is not proof: a commit that removes leaf 0 + // and adds someone in the same epoch places the invitee there, which let a crafted + // invite skip New Requests. So creation leaves a durable marker, stored with the ingest + // markers (hashed, so it cannot collide with an event id) and read back on restore. + private val createdLocally = mutableSetOf() + private val createdLocallyLock = KmpLock() + + private fun createdLocallyMarker(nostrGroupId: HexKey): HexKey = sha256("amethyst:marmot:created-locally:$nostrGroupId".encodeToByteArray()).toHexKey() + + private suspend fun markCreatedLocally(nostrGroupId: HexKey) { + createdLocallyLock.withLock { createdLocally.add(nostrGroupId) } + markTerminallyIngested(createdLocallyMarker(nostrGroupId)) + } + + /** Whether this device created [nostrGroupId]. */ + fun isCreatedLocally(nostrGroupId: HexKey): Boolean = createdLocallyLock.withLock { nostrGroupId in createdLocally } + suspend fun isTerminallyIngested(eventId: HexKey): Boolean = terminallyIngestedMutex.withLock { eventId in terminallyIngested } suspend fun markTerminallyIngested(eventId: HexKey) { @@ -1113,6 +1133,7 @@ class MarmotManager( inboundProcessor.trackGroup(nostrGroupId) desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) + markCreatedLocally(nostrGroupId) Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" } return nostrGroupId } @@ -1156,6 +1177,7 @@ class MarmotManager( inboundProcessor.trackGroup(nostrGroupId) desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) + markCreatedLocally(nostrGroupId) return nostrGroupId } @@ -2779,10 +2801,9 @@ class MarmotManager( chatroom.isOutOfSync.value = desync.isDesynced(nostrGroupId) // A group we created is ours: keep it out of "New Requests" across restarts. // `markAsKnown` at creation is in-memory only, and a creator who has not posted - // yet has nothing else that says so. The creator holds leaf 0 (RFC 9420 adds a - // joiner at the leftmost BLANK leaf, and leaf 0 is never blank while its creator - // is in the group), so an invitee only lands there after the creator has left. - if (groupManager.getGroup(nostrGroupId)?.leafIndex == 0) chatroom.ownerSentMessage = true + // yet has nothing else that says so. See [isCreatedLocally] for why this is not + // read off the tree. + if (isCreatedLocally(nostrGroupId)) chatroom.ownerSentMessage = true val previousCount = chatroom.members.value.size val members = memberPubkeys(nostrGroupId) chatroom.members.value = members diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt index 4bd030c460..4a8a9b30e8 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.quartz.marmot.GroupEventResult +import com.vitorpamplona.quartz.marmot.InMemoryIngestDedupStore import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState @@ -315,6 +316,27 @@ class MarmotDisbandTest { assertTrue(received is GroupEventResult.ApplicationMessage, "and reads the group again, got $received") } + @Test + fun `a group we created is still ours after a restart`() = + runBlocking { + // The marker is what survives, not the tree position: a fresh manager on the same + // stores has to find it on restore. + val signer = NostrSignerInternal(KeyPair()) + val states = SnapshotStateStore() + val messages = SnapshotMessageStore() + val bundles = SnapshotBundleStore() + val markers = InMemoryIngestDedupStore() + val before = MarmotManager(signer, states, messages, bundles, publisher = ACCEPTING_RELAY, ingestDedupStore = markers) + before.createCurrentProfileGroup(nostrGroupId, listOf("wss://relay.invalid"), GroupProfileV1("mine", "")) + + val after = MarmotManager(signer, states, messages, bundles, publisher = ACCEPTING_RELAY, ingestDedupStore = markers) + after.restoreAll() + val room = MarmotGroupChatroom(nostrGroupId) + after.syncMetadataTo(nostrGroupId, room) + + assertTrue(room.isKnown(emptySet()), "the creator's own group, after a restart") + } + @Test fun `rejoining a group we left clears the departure gate`() = runBlocking { From 90264a815d4a3e8bfb270bc09be617bbe51457d2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:29 -0400 Subject: [PATCH 32/37] fix(marmot): serialize a group's commit preparation commitAndPublish checked canPrepareLocalCommit and then staged and recorded its obligation with nothing in between stopping a second caller. The auto-commit of a member's leave runs from ingest while the user may be committing a rename or an add; both could pass the check and stage two commits from one epoch, forking the group. (Two racing leave commits were measured identical and harmless, 20/20; a leave racing a rename is not.) A per-group Mutex now covers check -> stage -> prepare; the publish itself stays outside it. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/commons/marmot/MarmotManager.kt | 79 +++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 5602436d5e..2fa48e3f6e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -1191,6 +1191,11 @@ class MarmotManager( val confirmed: Boolean, ) + private val commitPreparationLocks = mutableMapOf() + private val commitPreparationLocksLock = Mutex() + + private suspend fun commitPreparationLock(nostrGroupId: HexKey): Mutex = commitPreparationLocksLock.withLock { commitPreparationLocks.getOrPut(nostrGroupId) { Mutex() } } + /** * Prepare a local commit, publish it, and apply it only if publication was * acknowledged (`protocol-core/publish-lifecycle.md`). @@ -1212,41 +1217,49 @@ class MarmotManager( ignoringGate: LocalOutboundGate? = null, stage: suspend () -> MlsGroupManager.StagedCommit, ): CommitPublication { - requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate) - // A commit whose publish went unconfirmed leaves the group in - // `PendingPublish`, which correctly refuses new commits — but the only - // thing that ever resolved it was `restoreAll`, so one dropped socket - // wedged the group until the app was restarted. Retrying this group's - // obligations here makes the next attempt the recovery: republishing - // the same event is safe (a peer deduplicates it by id) and a retry - // that still fails leaves the group held exactly as before. - if (publishGate.lifecycle(nostrGroupId) == GroupLifecycleState.PENDING_PUBLISH) { - retryPendingPublishObligations(onlyGroupId = nostrGroupId) - } - check(publishGate.canPrepareLocalCommit(nostrGroupId, ignoringGate)) { - "Group $nostrGroupId cannot prepare a local commit " + - "(lifecycle=${publishGate.lifecycle(nostrGroupId)}, gate=${publishGate.outboundGate(nostrGroupId)})" - } + // One lock per group from the gate check to the durable obligation. The auto-commit of a + // member's leave runs from ingest while the user may be committing a rename or an add; + // the gate check alone let both pass before either recorded its obligation, and two + // commits staged from one epoch fork the group. + val (staged, event, obligation) = + commitPreparationLock(nostrGroupId).withLock { + requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate) + // A commit whose publish went unconfirmed leaves the group in + // `PendingPublish`, which correctly refuses new commits — but the only + // thing that ever resolved it was `restoreAll`, so one dropped socket + // wedged the group until the app was restarted. Retrying this group's + // obligations here makes the next attempt the recovery: republishing + // the same event is safe (a peer deduplicates it by id) and a retry + // that still fails leaves the group held exactly as before. + if (publishGate.lifecycle(nostrGroupId) == GroupLifecycleState.PENDING_PUBLISH) { + retryPendingPublishObligations(onlyGroupId = nostrGroupId) + } + check(publishGate.canPrepareLocalCommit(nostrGroupId, ignoringGate)) { + "Group $nostrGroupId cannot prepare a local commit " + + "(lifecycle=${publishGate.lifecycle(nostrGroupId)}, gate=${publishGate.outboundGate(nostrGroupId)})" + } - val staged = stage() - val event = - outboundProcessor.buildCommitEvent( - nostrGroupId = nostrGroupId, - commitBytes = staged.result.framedCommitBytes, - exporterKey = staged.result.preCommitExporterSecret, - ) + val staged = stage() + val event = + outboundProcessor.buildCommitEvent( + nostrGroupId = nostrGroupId, + commitBytes = staged.result.framedCommitBytes, + exporterKey = staged.result.preCommitExporterSecret, + ) - // Durable BEFORE the publish. Publishing first would leave a crash - // window in which peers have accepted a commit this client has no - // memory of preparing — and on restart it would generate a - // replacement, forking itself at the same epoch. - val obligation = - publishGate.prepare( - groupId = nostrGroupId, - staged = staged, - outboundBytes = event.signedEvent.toJson().encodeToByteArray(), - recipientScope = relays.map { it.url }, - ) + // Durable BEFORE the publish. Publishing first would leave a crash + // window in which peers have accepted a commit this client has no + // memory of preparing — and on restart it would generate a + // replacement, forking itself at the same epoch. + val obligation = + publishGate.prepare( + groupId = nostrGroupId, + staged = staged, + outboundBytes = event.signedEvent.toJson().encodeToByteArray(), + recipientScope = relays.map { it.url }, + ) + Triple(staged, event, obligation) + } val confirmed = try { From 0c9f4cbf0b8d908eef4fcdb71d9838f2557985b8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:31 -0400 Subject: [PATCH 33/37] fix(marmot): a Welcome that ran out of retries waits for the next app start Relays re-deliver two days of gift wraps on every re-subscription, and each delivery of a Welcome that kept failing started a fresh 30s/2m/10m chain, so a Welcome that can never apply was re-run through MLS for as long as relays kept it. Exhausted Welcomes are now remembered for the session: re-deliveries skip them, and the next app start still gives them one more try. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 19 +++++++++++++++++-- .../loggedIn/DecryptAndIndexProcessor.kt | 7 ++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index e767ce649d..4e2d9a7f8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -94,10 +94,25 @@ class AccountMarmotActions( // Welcome rumor ids with a retry already scheduled, so a relay re-delivering the same // wrap while one waits does not start a second chain of retries. private val welcomeRetries = mutableSetOf() + + // Welcome rumor ids whose retries ran out this session. Relays re-deliver two days of gift + // wraps on every re-subscription, and each delivery used to start a fresh chain, so a + // Welcome that can never apply was re-run through MLS for as long as relays kept it. + // In memory on purpose: the next app start still gives it one more try. + private val welcomeRetriesExhausted = mutableSetOf() private val welcomeRetriesLock = KmpLock() - /** True when [welcomeId] had no retry pending and now has one. */ - fun claimWelcomeRetry(welcomeId: HexKey): Boolean = welcomeRetriesLock.withLock { welcomeRetries.add(welcomeId) } + /** True when [welcomeId] had no retry pending, has retries left, and now has one pending. */ + fun claimWelcomeRetry(welcomeId: HexKey): Boolean = + welcomeRetriesLock.withLock { + welcomeId !in welcomeRetriesExhausted && welcomeRetries.add(welcomeId) + } + + fun markWelcomeRetriesExhausted(welcomeId: HexKey) { + welcomeRetriesLock.withLock { welcomeRetriesExhausted.add(welcomeId) } + } + + fun welcomeRetriesExhausted(welcomeId: HexKey): Boolean = welcomeRetriesLock.withLock { welcomeId in welcomeRetriesExhausted } fun releaseWelcomeRetry(welcomeId: HexKey) { welcomeRetriesLock.withLock { welcomeRetries.remove(welcomeId) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 71178e26dc..b9924db662 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -406,6 +406,8 @@ private suspend fun processMarmotWelcomeFlow( // A Welcome that already joined (or never can) is done. Replays of its wrap are routine: // every re-subscription re-delivers the last two days of gift wraps. if (manager.isTerminallyIngested(innerEvent.id)) return + // Out of retries this session: a re-delivered wrap waits for the next app start. + if (attempt == 0 && account.marmot.welcomeRetriesExhausted(innerEvent.id)) return // "h" tag is optional per MIP-02 — some senders (e.g. whitenoise-rs) omit it. // nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension instead. @@ -480,7 +482,10 @@ private fun scheduleWelcomeRetry( account: Account, attempt: Int, ) { - if (attempt >= WELCOME_RETRY_DELAYS_MS.size) return + if (attempt >= WELCOME_RETRY_DELAYS_MS.size) { + account.marmot.markWelcomeRetriesExhausted(welcome.id) + return + } if (!account.marmot.claimWelcomeRetry(welcome.id)) return account.scope.launch(Dispatchers.IO) { delay(WELCOME_RETRY_DELAYS_MS[attempt]) From 1f05937752bba0f65dc1ab49e7e29de571560c63 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:32 -0400 Subject: [PATCH 34/37] fix(marmot): an out-of-sync reset survives leaving the chat The reset ran on the banner's rememberCoroutineScope, cancelled as soon as the chat closed; leaving right after confirming could stop it between dropping the local state, flagging the group as awaiting a re-invite, and publishing the KeyPackage the admin needs. It now runs on the account scope. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 9 ++++++++- .../loggedIn/chats/marmotGroup/MarmotGroupChatView.kt | 3 +-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index d8bcd839b5..f92eae835c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2556,7 +2556,14 @@ class AccountViewModel( /** True when this account has somewhere to upload a group's encrypted media. */ fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty() - suspend fun resetOutOfSyncMarmotGroup(nostrGroupId: String) = account.marmot.resetOutOfSyncMarmotGroup(nostrGroupId) + /** + * On the account scope, not the screen's: the reset drops local state, flags the group as + * awaiting a re-invite and publishes a KeyPackage, and leaving the chat mid-way must not + * stop it between those steps. + */ + fun resetOutOfSyncMarmotGroup(nostrGroupId: String) { + account.scope.launch(Dispatchers.IO) { account.marmot.resetOutOfSyncMarmotGroup(nostrGroupId) } + } suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) { account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 53ad82a1a6..f31fc28a75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -416,7 +416,6 @@ private fun MarmotOutOfSyncBanner( nostrGroupId: HexKey, accountViewModel: AccountViewModel, ) { - val scope = rememberCoroutineScope() var confirming by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { @@ -439,7 +438,7 @@ private fun MarmotOutOfSyncBanner( TextButton( onClick = { confirming = false - scope.launch(Dispatchers.IO) { accountViewModel.resetOutOfSyncMarmotGroup(nostrGroupId) } + accountViewModel.resetOutOfSyncMarmotGroup(nostrGroupId) }, ) { Text(stringRes(Res.string.marmot_out_of_sync_reset)) } }, From f088a3a7fb87f5ffb444be213fceeabf678356dd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:33 -0400 Subject: [PATCH 35/37] perf(marmot): skip the relay-icon lookup for rows that show members' faces An unnamed, avatarless group (a White Noise 1:1) draws its members, but the row resolved the group picture first, a NIP-11 relay-info fetch per row that was then thrown away. The picture is now resolved only on the branch that draws it. Co-Authored-By: Claude Opus 5.5 --- .../chats/rooms/ChatroomHeaderCompose.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index dd8634d812..d782720627 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -521,16 +521,6 @@ private fun MarmotGroupRoomCompose( val otherMembers = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) } val groupName = marmotGroupTitle(displayName, otherMembers, chatroom.nostrGroupId, accountViewModel) - // Prefer the group's own avatar — the plain https link first, then the - // encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon - // of one of the group's relays (fetched on a cache miss). - val channelPicture = - if (avatarUrl != null || image != null) { - rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys) - } else { - loadMarmotRelayIcon(relays) - } - // The row is handed the group's placeholder note when it has no messages. val lastContent = marmotGroupPreviewText(lastMessage.takeIf { it.event != null }, accountViewModel) @@ -551,6 +541,17 @@ private fun MarmotGroupRoomCompose( return } + // Prefer the group's own avatar — the plain https link first, then the + // encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon + // of one of the group's relays (fetched on a cache miss). Resolved only here: a row that + // shows its members' faces above never draws it, and the relay icon is a NIP-11 fetch. + val channelPicture = + if (avatarUrl != null || image != null) { + rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys) + } else { + loadMarmotRelayIcon(relays) + } + ChannelName( channelIdHex = chatroom.nostrGroupId, channelPicture = channelPicture, From bfb0eb53f071e9633f59b5d9770314102846da95 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:35 -0400 Subject: [PATCH 36/37] refactor(marmot): drop the retry arm that could never reopen anything The catch-up retry also queued "decrypts on no canonical epoch" failures. The inbound processor remembers that result's id, so a retry only ever came back as a duplicate, and the outer layer had already opened on an epoch we hold, so no later commit could help it. Only an undecryptable outer layer is retried now; the comment that said otherwise is fixed. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/commons/marmot/MarmotSyncPolicy.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt index 3fc71f2066..bf579e4ab7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt @@ -145,7 +145,7 @@ class MarmotSyncPolicy( // Relays answer newest-first and several relays interleave, but a kind:445 can only // be opened at the epoch its predecessors built: a message sent after a commit fails - // (UndecryptableOuter, "no canonical epoch") if it is tried before that commit. The + // (UndecryptableOuter) if it is tried before that commit. The // cursor then moves past it and it is never fetched again, which is how an offline // member came back missing a rename and every message after a membership change. // Welcomes first (they create the groups), then group events oldest first. @@ -254,7 +254,10 @@ class MarmotSyncPolicy( } } -/** Failed only because the epoch it was sent at isn't reached yet; a later commit may open it. */ -private fun MarmotIngestResult.couldOpenAfterACommit(): Boolean = - this is MarmotIngestResult.UndecryptableOuter || - (this is MarmotIngestResult.Failure && message.startsWith(MarmotManager.NO_CANONICAL_EPOCH_ERROR)) +/** + * Failed only because the epoch it was sent at isn't reached yet; a later commit may open it. + * Only an undecryptable outer layer qualifies. A "no canonical epoch" failure already opened + * its outer layer on an epoch we hold, so no commit can help it, and the inbound processor + * remembers its id: a retry would only come back as a duplicate. + */ +private fun MarmotIngestResult.couldOpenAfterACommit(): Boolean = this is MarmotIngestResult.UndecryptableOuter From af983086ffd3097ed135470eafdad9266838520f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:36 -0400 Subject: [PATCH 37/37] test(mls): the group stays usable after an empty-path commit The existing test only checked the commit's shape. This one has the removed member apply its removal, then adds a new member and exchanges messages with the committer both ways, which exercises the commit-secret choice for an empty direct path. Co-Authored-By: Claude Opus 5.5 --- .../quartz/mls/group/MlsGroupNegativeTest.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt index 5fa7359019..fb075f828b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt @@ -105,6 +105,44 @@ class MlsGroupNegativeTest { assertEquals(1, fx.alice.members().size) } + /** + * The empty-path commit also changes how the committer derives the new epoch (the commit + * secret is the leaf's own path secret). No remaining member re-derives it, so the check + * is that the group stays usable: the removed member sees the removal, and a member + * added afterwards exchanges messages with the committer in both directions. + */ + @Test + fun groupKeepsWorkingAfterAnEmptyPathCommit() { + val fx = twoMemberGroup() + val removal = fx.alice.removeMember(1) + val parts = parseCommit(removal.framedCommitBytes) + fx.bob.processCommit( + commitBytes = parts.content, + senderLeafIndex = parts.senderLeafIndex, + confirmationTag = parts.confirmationTag, + signature = parts.signature, + wireFormat = WireFormat.PUBLIC_MESSAGE, + ) + // Bob applies his own removal: the empty path does not stop the tree update. + assertEquals(1, fx.bob.members().size) + + val daveBundle = fx.alice.createKeyPackage(identity = "dave".encodeToByteArray(), signingKey = ByteArray(32) { 3 }) + val addDave = fx.alice.addMember(daveBundle.keyPackage.toTlsBytes()) + val dave = MlsGroup.processWelcome(addDave.welcomeBytes!!, daveBundle) + assertEquals(fx.alice.epoch, dave.epoch) + + val fromAlice = fx.alice.encrypt("after the removal".encodeToByteArray()) + assertEquals("after the removal", dave.decrypt(fromAlice).content.decodeToString()) + val fromDave = dave.encrypt("and back".encodeToByteArray()) + assertEquals( + "and back", + fx.alice + .decrypt(fromDave) + .content + .decodeToString(), + ) + } + /** Baseline: an honest commit applies and advances Bob's epoch. */ @Test fun honestCommitIsAccepted() {