From 3acff81baa5769e922a05f93d3a23435243ddcf8 Mon Sep 17 00:00:00 2001 From: Daneul Kim Date: Thu, 28 May 2026 22:12:28 +0900 Subject: [PATCH 01/51] Fix live stream chat relay fallback --- .../publicChannels/send/ChannelNewMessageViewModel.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index e9b59f6c10..480757da65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -297,7 +297,13 @@ open class ChannelNewMessageViewModel : val version = draftTag.current cancel() - accountViewModel.account.signAndSendPrivately(template, channelRelays) + if (channel is LiveActivitiesChannel) { + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { + channelRelays.toList() + } + } else { + accountViewModel.account.signAndSendPrivately(template, channelRelays) + } accountViewModel.viewModelScope.launch(Dispatchers.IO) { accountViewModel.account.deleteDraftIgnoreErrors(version) } From 0ff512c09e2e3d99c88b28e652f38f77503699e8 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 28 May 2026 15:57:45 +0000 Subject: [PATCH 02/51] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 61 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 56 +++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 55 +++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 736d7190f0..28a891b10e 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -745,6 +745,36 @@ Zatím žádné servery. Přidejte server níže nebo vyberte z doporučených. Videa Články + Hudba + Playlisty + Nová hudební stopa + Nový playlist + Název + Název stopy + Interpret + Jméno interpreta + URL zvuku + https://… .mp3 + URL obrázku obalu (volitelné) + Album (volitelné) + Délka v sekundách (volitelné) + Text nebo kredity (volitelné) + Smazat tuto stopu + Publikovat NIP-09 smazání této hudební stopy? + Přidat do playlistu + Přidat do playlistu + Zatím nemáte žádné playlisty. Pomocí pole výše si nějaký vytvořte. + Spravovat všechny playlisty + Nový playlist + Název playlistu + Vytvořit + (playlist bez názvu) + + %1$d stopa + %1$d stopy + %1$d stopy + %1$d stop + Soukromé záložky Veřejné záložky Přidat do soukromých záložek @@ -912,6 +942,7 @@ Adresa Smazat platební cíl Nebyla nalezena žádná aplikace pro zpracování této platby. Nainstalujte prosím kompatibilní peněženku. + Není nainstalována žádná aplikace pro platby typu %1$s. Nainstalujte prosím kompatibilní peněženku. Nelze otevřít platbu Nezačaté Kompresuje @@ -1381,7 +1412,30 @@ Komentář k webové stránce Komentář k externímu zdroji %1$d min čtení + + %1$d stopa + %1$d stopy + %1$d stopy + %1$d stop + + + +%1$d další stopa + +%1$d další stopy + +%1$d další stopy + +%1$d dalších stop + + Sdílený + Soukromý + Neznámá stopa + Načítání stopy… + Nahrát obrázek obalu + Vyberte čtvercový obrázek, který bude obalem této stopy. + Nahrát zvukový soubor + Vyberte MP3, WAV nebo FLAC. Při uložení bude nahrán na váš mediální server. + Zvukový soubor připraven k nahrání + Nahrávání… Nahrávání obalu a zvuku a publikování. Bude pokračovat, i když opustíte obrazovku. + Nahrávání selhalo: %1$s Načítání umístění Žádná lokace oprávnění Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující @@ -1476,6 +1530,9 @@ Peněženka %1$s Chyba při otevírání aplikace pro podpis Signner aplikace nebyla nalezena. Zkontrolujte, zda aplikace nebyla odinstalována + Toto video nelze přehrát + Tento kodek nebo formát není na tomto zařízení podporován (%1$s). Zkuste jej otevřít v prohlížeči. + Otevřít v prohlížeči Požadavek na podpis byl zamítnut Žádost o podpis byla zamítnuta Ujistěte se, že podepisující aplikace autorizovala tuto transakci @@ -1624,6 +1681,7 @@ Přesto nahrát Nepodařilo se odstranit soukromá metadata z média. Nahrávání zrušeno. Nahrávání zrušeno + Nelze odstranit metadata z AVIF: %1$s Upravit koncept Přihlášení pomocí QR kódu Trasa @@ -1686,6 +1744,7 @@ Neplatné URI NWC připojení Posunout nahoru Posunout dolů + Změnit pořadí peněženek On-chain transakce Pro tento účet není k dispozici žádná on-chain adresa. Není nakonfigurován žádný on-chain backend. @@ -2284,6 +2343,8 @@ Uživatelská nastavení Audio hlavička Audio stopa + Hudební stopa + Hudební playlist Ocenění odznaků Definice odznaků Přijatá sada odznaků diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index bcda0b7079..22af06edea 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -569,6 +569,7 @@ anz der Bedingungen ist erforderlich Über Plattformen Themen + Verknüpfungen Neueste Version Kommentare Sei der Erste, der kommentiert. @@ -734,6 +735,34 @@ anz der Bedingungen ist erforderlich Noch keine Nest-Server. Füge unten einen hinzu oder wähle einen empfohlenen Server. Videos Artikel + Musik + Wiedergabelisten + Neuer Musiktitel + Neue Playlist + Titel + Titelname + Künstler + Name des Künstlers + Audio-URL + https://… .mp3 + URL des Coverbilds (optional) + Album (optional) + Dauer in Sekunden (optional) + Songtext oder Credits (optional) + Diesen Titel löschen + NIP-09-Löschung für diesen Musiktitel veröffentlichen? + Zur Playlist hinzufügen + Zur Playlist hinzufügen + Du hast noch keine eigenen Playlists. Verwende das Feld oben, um eine zu erstellen. + Alle Playlists verwalten + Neue Playlist + Playlist-Name + Erstellen + (Playlist ohne Titel) + + %1$d Titel + %1$d Titel + Private Lesezeichen Öffentliche Lesezeichen Zu den privaten Lesezeichen hinzufügen @@ -901,6 +930,7 @@ anz der Bedingungen ist erforderlich Adresse Zahlungsziel löschen Keine App gefunden, die diese Zahlung verarbeiten kann. Bitte installieren Sie eine kompatible Wallet. + Es ist keine App installiert, die Zahlungen vom Typ %1$s verarbeiten kann. Bitte installiere eine kompatible Wallet. Zahlung konnte nicht geöffnet werden Noch nicht angefangen Komprimieren @@ -1369,7 +1399,26 @@ anz der Bedingungen ist erforderlich Eine Webseite kommentieren Eine externe Ressource kommentieren %1$d Min. Lesezeit + + %1$d Titel + %1$d Titel + + + +%1$d weiterer Titel + +%1$d weitere Titel + + Kollaborativ + Privat + Unbekannter Titel + Titel wird geladen… + Coverbild hochladen + Wähle ein quadratisches Bild als Artwork für diesen Titel. + Audiodatei hochladen + Wähle eine MP3, WAV oder FLAC. Sie wird beim Speichern auf deinen Medienserver hochgeladen. + Audiodatei bereit zum Hochladen + Wird hochgeladen… Cover und Audio werden hochgeladen und veröffentlicht. Der Vorgang läuft weiter, auch wenn du den Bildschirm verlässt. + Hochladen fehlgeschlagen: %1$s Standort wird geladen Keine Standortberechtigungen Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten @@ -1464,6 +1513,9 @@ anz der Bedingungen ist erforderlich Brieftasche %1$s Fehler beim Öffnen der Signatur-App Die Unterzeichner-App konnte nicht gefunden werden. Prüfen Sie, ob die App nicht deinstalliert wurde + Dieses Video kann nicht abgespielt werden + Der Codec oder das Format wird auf diesem Gerät nicht unterstützt (%1$s). Versuche, es im Browser zu öffnen. + Im Browser öffnen Signaturanfrage abgelehnt Signaturanfrage abgelehnt Stellen Sie sicher, dass die Unterzeichner-Anwendung diese Transaktion autorisiert hat @@ -1612,6 +1664,7 @@ anz der Bedingungen ist erforderlich Trotzdem hochladen Private Metadaten konnten nicht aus der Mediendatei entfernt werden. Hochladen abgebrochen. Hochladen abgebrochen + Metadaten können nicht aus AVIF entfernt werden: %1$s Entwurf bearbeiten Einloggen mit QR-Code Route @@ -1674,6 +1727,7 @@ anz der Bedingungen ist erforderlich Ungültige NWC-Verbindungs-URI Nach oben Nach unten + Wallets neu anordnen On-Chain-Transaktionen Keine On-Chain-Adresse für dieses Konto verfügbar. Kein On-Chain-Backend konfiguriert. @@ -2264,6 +2318,8 @@ anz der Bedingungen ist erforderlich Benutzereinstellungen Audio-Header Audiospur + Musiktitel + Musik-Playlist Abzeichen-Auszeichnungen Abzeichen-Definitionen Akzeptiertes Abzeichen-Set diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 07c1f69fdc..885d028787 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -729,6 +729,34 @@ Inga nest-servrar än. Lägg till en nedan eller välj en rekommenderad server. Videor Artiklar + Musik + Spellistor + Nytt musikspår + Ny spellista + Titel + Spårtitel + Artist + Artistens namn + Ljud-URL + https://… .mp3 + URL till omslagsbild (valfritt) + Album (valfritt) + Längd i sekunder (valfritt) + Sångtext eller medverkande (valfritt) + Ta bort det här spåret + Publicera en NIP-09-borttagning för det här musikspåret? + Lägg till i spellista + Lägg till i spellista + Du äger inga spellistor än. Använd fältet ovan för att skapa en. + Hantera alla spellistor + Ny spellista + Spellistans namn + Skapa + (namnlös spellista) + + %1$d spår + %1$d spår + Privata Bokmärken Publika Bokmärken Lägg till i Privata Bokmärken @@ -896,6 +924,7 @@ Adress Ta bort betalningsmottagare Ingen app hittades för att hantera denna betalning. Installera en kompatibel plånbok. + Ingen app installerad för att hantera %1$s-betalningar. Installera en kompatibel plånbok. Det gick inte att öppna betalningen Ej påbörjad Komprimerar @@ -1364,7 +1393,26 @@ Kommentera en webbplats Kommentera en extern resurs %1$d min läsning + + %1$d spår + %1$d spår + + + +%1$d spår till + +%1$d spår till + + Delad + Privat + Okänt spår + Läser in spår… + Ladda upp en omslagsbild + Välj en kvadratisk bild som omslag för det här spåret. + Ladda upp en ljudfil + Välj en MP3, WAV eller FLAC. Den laddas upp till din mediaserver när du sparar. + Ljudfil redo att laddas upp + Laddar upp… Laddar upp omslag och ljud och publicerar. Detta fortsätter även om du lämnar skärmen. + Uppladdning misslyckades: %1$s Laddar position Inga platsbehörigheter Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande @@ -1459,6 +1507,9 @@ Plånbok %1$s Fel vid öppning av signeringsapp Signeringsprogrammet kunde inte hittas. Kontrollera att appen inte har avinstallerats + Kan inte spela upp den här videon + Codec eller format stöds inte på den här enheten (%1$s). Försök öppna den i din webbläsare. + Öppna i webbläsare Signeringsförfrågan avvisad Underteckningsbegäran avvisad Kontrollera att signeringsprogrammet har godkänt denna transaktion @@ -1607,6 +1658,7 @@ Ladda upp ändå Kunde inte ta bort privat metadata från media. Uppladdning avbruten. Uppladdning avbruten + Kan inte ta bort metadata från AVIF: %1$s Redigera utkast Logga in med QR-kod Rutt @@ -1669,6 +1721,7 @@ Ogiltig URI för NWC-anslutning Flytta upp Flytta ner + Ändra ordning på plånböcker On-chain-transaktioner Ingen on-chain-adress tillgänglig för detta konto. Ingen on-chain-backend är konfigurerad. @@ -2259,6 +2312,8 @@ Användarinställningar Ljudhuvud Ljudspår + Musikspår + Musikspellista Utmärkelser Utmärkelsedefinitioner Accepterad emblemuppsättning From b47cdf5b96bf2ae3c8015772a63cad65d72a4ae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 20:10:39 +0000 Subject: [PATCH 03/51] feat(quartz): add NIP-F4 podcast event support Implements the four event kinds defined by NIP-F4 so Quartz can parse and build native Nostr podcasts: kind:10154 show metadata, kind:10064 author counter-claim, kind:54 episode, and kind:10054 favorite-podcasts list. All four are registered in EventFactory so the existing JSON deserialization pipeline returns typed instances. Tag classes mirror the per-event package layout used by the experimental music module. --- .../authored/AuthoredPodcastsEvent.kt | 80 +++++++++ .../authored/TagArrayBuilderExt.kt | 26 +++ .../episode/PodcastEpisodeEvent.kt | 82 +++++++++ .../episode/TagArrayBuilderExt.kt | 35 ++++ .../nipF4Podcasts/episode/tags/AudioTag.kt | 67 ++++++++ .../episode/tags/DescriptionTag.kt | 39 +++++ .../nipF4Podcasts/episode/tags/ImageTag.kt | 39 +++++ .../nipF4Podcasts/episode/tags/TitleTag.kt | 39 +++++ .../favorites/FavoritePodcastsListEvent.kt | 156 +++++++++++++++++ .../metadata/PodcastMetadataEvent.kt | 102 ++++++++++++ .../metadata/TagArrayBuilderExt.kt | 38 +++++ .../nipF4Podcasts/metadata/tags/AuthorTag.kt | 76 +++++++++ .../metadata/tags/DescriptionTag.kt | 39 +++++ .../nipF4Podcasts/metadata/tags/ImageTag.kt | 39 +++++ .../nipF4Podcasts/metadata/tags/TitleTag.kt | 39 +++++ .../nipF4Podcasts/metadata/tags/WebsiteTag.kt | 41 +++++ .../quartz/utils/EventFactory.kt | 8 + .../AuthoredPodcastsEventTest.kt | 79 +++++++++ .../FavoritePodcastsListEventTest.kt | 157 ++++++++++++++++++ .../nipF4Podcasts/PodcastEpisodeEventTest.kt | 116 +++++++++++++ .../nipF4Podcasts/PodcastMetadataEventTest.kt | 136 +++++++++++++++ 21 files changed, 1433 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt new file mode 100644 index 0000000000..abf626b368 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt @@ -0,0 +1,80 @@ +/* + * 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.quartz.nipF4Podcasts.authored + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 author counter-claim (`kind:10064`). A user authors this to assert which + * podcast pubkeys they actually create — used by clients to confirm that the + * authorship claim made by a [com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent] + * is reciprocated by the alleged author, and to discover all podcasts authored + * by a given user. + */ +@Immutable +class AuthoredPodcastsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + fun authored() = tags.mapNotNull(UserTag::parse) + + fun authoredKeys() = tags.mapNotNull(UserTag::parseKey) + + fun authors(podcastPubKey: HexKey) = tags.any { UserTag.isTagged(it, podcastPubKey) } + + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) + + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) + + companion object { + const val KIND = 10064 + const val ALT_DESCRIPTION = "List of authored podcasts" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun build( + podcasts: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + podcasts.forEach { podcast(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5c366ded4f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/* + * 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.quartz.nipF4Podcasts.authored + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag + +fun TagArrayBuilder.podcast(podcast: UserTag) = add(podcast.toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt new file mode 100644 index 0000000000..5afb2fd4dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -0,0 +1,82 @@ +/* + * 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.quartz.nipF4Podcasts.episode + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 podcast episode (`kind:54`), regular event authored by the podcast pubkey. + * + * `content` is the markdown-formatted episode notes. Title, image, short description, + * and one or more audio URLs live in tags so clients can render listings without + * parsing markdown. + */ +@Immutable +class PodcastEpisodeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun audios() = tags.mapNotNull(AudioTag::parse) + + companion object { + const val KIND = 54 + const val ALT_DESCRIPTION_PREFIX = "Podcast episode" + + fun build( + title: String, + description: String, + audios: List, + markdownContent: String = "", + image: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, markdownContent, createdAt) { + alt("$ALT_DESCRIPTION_PREFIX: $title") + + title(title) + description(description) + image?.let { image(it) } + audios.forEach { audio(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..7d98b5560f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nipF4Podcasts.episode + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.audio(audio: AudioTag) = add(AudioTag.assemble(audio)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt new file mode 100644 index 0000000000..d1aec68bde --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nipF4Podcasts.episode.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-F4 episode audio tag: `["audio", "", ""]`. + * + * Multiple `audio` tags are allowed — typically used to provide the same episode + * in different containers/codecs (e.g. mp3 + opus). When present, [mediaType] is + * a MIME type the client can use to pick a stream it can decode without HEAD-ing + * the URL first. + */ +@Stable +class AudioTag( + val url: String, + val mediaType: String? = null, +) { + fun toTagArray() = assemble(url, mediaType) + + companion object { + const val TAG_NAME = "audio" + + fun parse(tag: Array): AudioTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + return AudioTag(tag[1], mediaType) + } + + fun assemble( + url: String, + mediaType: String? = null, + ): Array = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(audio: AudioTag) = assemble(audio.url, audio.mediaType) + + fun assemble(audios: List) = audios.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt new file mode 100644 index 0000000000..de1eb3154a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nipF4Podcasts.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt new file mode 100644 index 0000000000..9057dbcbd1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nipF4Podcasts.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt new file mode 100644 index 0000000000..fcd3bca442 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nipF4Podcasts.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt new file mode 100644 index 0000000000..409e1ba484 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt @@ -0,0 +1,156 @@ +/* + * 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.quartz.nipF4Podcasts.favorites + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 user favorite-podcasts list (`kind:10054`). Per the spec this advertises + * the podcasts the user listens to as a soft public recommendation. Items are + * `p` tags pointing at podcast pubkeys. + * + * Follows the NIP-51 convention of also allowing private items stored encrypted + * in `content`, so a user can keep guilty pleasures off the public list while + * still using the same event kind for their own subscription state. + */ +@Immutable +class FavoritePodcastsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavorites() = tags.mapNotNull(UserTag::parse) + + suspend fun privateFavorites(signer: NostrSigner) = privateTags(signer)?.mapNotNull(UserTag::parse) + + companion object { + const val KIND = 10054 + const val ALT = "Favorite podcasts list" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "") + + suspend fun create( + publicFavorites: List = emptyList(), + privateFavorites: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent = + resign( + content = PrivateTagsInContent.encryptNip44(privateFavorites.map { it.toTagArray() }.toTypedArray(), signer), + tags = publicFavorites.map { it.toTagArray() }.toTypedArray(), + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: FavoritePodcastsListEvent, + podcast: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoritePodcastsListEvent, + podcast: UserTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent { + val idOnly = podcast.toTagIdOnly() + val privateTags = earlierVersion.privateTags(signer) + return if (privateTags != null) { + resign( + privateTags = privateTags.remove(idOnly), + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + return signer.sign(createdAt, KIND, newTags, content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt new file mode 100644 index 0000000000..44ad1fd43b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt @@ -0,0 +1,102 @@ +/* + * 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.quartz.nipF4Podcasts.metadata + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 show-level metadata for a podcast. Authored by the podcast's own pubkey + * (each podcast is its own Nostr keypair). Replaceable: each new event of this + * kind replaces the previous version for that pubkey. + * + * Podcast-specific clients SHOULD read from [PodcastMetadataEvent] directly and + * ignore the podcast's `kind:0` profile, per spec — the show name and cover art + * shipped here are the canonical ones for podcast UIs even if the same pubkey + * also publishes a microblogging profile. + */ +@Immutable +class PodcastMetadataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun websites() = tags.mapNotNull(WebsiteTag::parse) + + /** + * Returns claimed authors and their roles. The spec warns these claims are + * unverified — a podcast can name anyone as author. Before surfacing an author + * to users, cross-check with an [com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent] + * counter-claim signed by that author's own key. + */ + fun claimedAuthors() = tags.mapNotNull(AuthorTag::parse) + + companion object { + const val KIND = 10154 + const val ALT_DESCRIPTION = "Podcast metadata" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun build( + title: String, + image: String, + description: String, + websites: List = emptyList(), + authors: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + + title(title) + image(image) + description(description) + + websites.forEach { website(it) } + authors.forEach { author(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5948af9138 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt @@ -0,0 +1,38 @@ +/* + * 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.quartz.nipF4Podcasts.metadata + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.website(url: String) = add(WebsiteTag.assemble(url)) + +fun TagArrayBuilder.author(author: AuthorTag) = add(AuthorTag.assemble(author)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt new file mode 100644 index 0000000000..23deb816ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt @@ -0,0 +1,76 @@ +/* + * 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.quartz.nipF4Podcasts.metadata.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-F4 author p-tag: `["p", "", ""]`. + * + * Role is optional and, per spec, may be `"host"`, `"cohost"`, or `"editor"`. + * Unknown role strings are preserved verbatim so future role values round-trip; + * clients should validate against [Role] before displaying. + * + * NOTE: this tag overloads `p`'s third slot for role, NOT the usual relay hint. + * Don't reuse this parser on p-tags from other event kinds. + */ +@Stable +class AuthorTag( + val pubKey: HexKey, + val role: String? = null, +) { + fun toTagArray() = assemble(pubKey, role) + + companion object { + const val TAG_NAME = "p" + + const val ROLE_HOST = "host" + const val ROLE_COHOST = "cohost" + const val ROLE_EDITOR = "editor" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): AuthorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + val role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + return AuthorTag(tag[1], role) + } + + fun assemble( + pubKey: HexKey, + role: String? = null, + ): Array = + if (role.isNullOrEmpty()) { + arrayOf(TAG_NAME, pubKey) + } else { + arrayOf(TAG_NAME, pubKey, role) + } + + fun assemble(author: AuthorTag) = assemble(author.pubKey, author.role) + + fun assemble(authors: List) = authors.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt new file mode 100644 index 0000000000..41e7707c99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt new file mode 100644 index 0000000000..d00c29ecc9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt new file mode 100644 index 0000000000..32bfbd2817 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt new file mode 100644 index 0000000000..95f187f6f7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class WebsiteTag { + companion object { + const val TAG_NAME = "website" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + + fun assemble(urls: List) = urls.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 08ffa2caef..1042cf1483 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -291,6 +291,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent interface EventBuilder { fun build( @@ -534,6 +538,10 @@ class EventFactory { ZapPollEvent.KIND -> ZapPollEvent(id, pubKey, createdAt, tags, content, sig) PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig) PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig) + PodcastMetadataEvent.KIND -> PodcastMetadataEvent(id, pubKey, createdAt, tags, content, sig) + PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig) + AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig) + FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig) ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt new file mode 100644 index 0000000000..98cba1a688 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt @@ -0,0 +1,79 @@ +/* + * 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.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AuthoredPodcastsEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + private val unrelated = "0000000000000000000000000000000000000000000000000000000000000001" + + @Test + fun `kind is 10064`() { + assertEquals(10064, AuthoredPodcastsEvent.KIND) + } + + @Test + fun `build round-trips podcast pubkeys`() { + val template = + AuthoredPodcastsEvent.build( + podcasts = listOf(UserTag(podcast1), UserTag(podcast2)), + ) + val event = signer.sign(template) + + assertEquals(listOf(podcast1, podcast2), event.authoredKeys()) + assertTrue(event.authors(podcast1)) + assertTrue(event.authors(podcast2)) + assertFalse(event.authors(unrelated)) + } + + @Test + fun `is replaceable with empty d-tag`() { + val template = AuthoredPodcastsEvent.build(emptyList()) + val event = signer.sign(template) + + assertEquals("", event.dTag()) + } + + @Test + fun `linkedPubKeys returns all authored podcast keys`() { + val template = + AuthoredPodcastsEvent.build( + podcasts = listOf(UserTag(podcast1), UserTag(podcast2)), + ) + val event = signer.sign(template) + + assertEquals(setOf(podcast1, podcast2), event.linkedPubKeys().toSet()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt new file mode 100644 index 0000000000..cf177d88ae --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt @@ -0,0 +1,157 @@ +/* + * 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.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class FavoritePodcastsListEventTest { + private val signer = + NostrSignerInternal( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + + @Test + fun `kind is 10054`() { + assertEquals(10054, FavoritePodcastsListEvent.KIND) + } + + @Test + fun `create with public favorites round-trips`() = + runTest { + val event = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val pubs = event.publicFavorites() + assertEquals(2, pubs.size) + assertEquals(podcast1, pubs[0].pubKey) + assertEquals(podcast2, pubs[1].pubKey) + } + + @Test + fun `create with private favorites hides them from public tags`() = + runTest { + val event = + FavoritePodcastsListEvent.create( + privateFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + assertFalse( + event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Private favorite must not be exposed as a public p-tag", + ) + assertTrue(event.content.isNotEmpty(), "Content must hold encrypted private tags") + + val priv = assertNotNull(event.privateFavorites(signer)) + assertEquals(1, priv.size) + assertEquals(podcast1, priv[0].pubKey) + } + + @Test + fun `add public favorite appends to list and dedupes by pubkey`() = + runTest { + val first = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val second = + FavoritePodcastsListEvent.add( + earlierVersion = first, + podcast = UserTag(podcast2), + isPrivate = false, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertEquals( + listOf(podcast1, podcast2), + second.publicFavorites().map { it.pubKey }, + ) + + // Adding podcast1 again must not double it. + val third = + FavoritePodcastsListEvent.add( + earlierVersion = second, + podcast = UserTag(podcast1), + isPrivate = false, + signer = signer, + createdAt = 1_700_000_002, + ) + assertEquals( + listOf(podcast2, podcast1), + third.publicFavorites().map { it.pubKey }, + ) + } + + @Test + fun `remove drops the matching pubkey from public tags`() = + runTest { + val first = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val second = + FavoritePodcastsListEvent.remove( + earlierVersion = first, + podcast = UserTag(podcast1), + signer = signer, + createdAt = 1_700_000_001, + ) + + assertEquals(listOf(podcast2), second.publicFavorites().map { it.pubKey }) + } + + @Test + fun `alt tag is always present`() = + runTest { + val event = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + assertTrue(event.tags.any { it[0] == "alt" && it[1] == FavoritePodcastsListEvent.ALT }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt new file mode 100644 index 0000000000..e841e19c7a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt @@ -0,0 +1,116 @@ +/* + * 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.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastEpisodeEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 54`() { + assertEquals(54, PodcastEpisodeEvent.KIND) + } + + @Test + fun `build round-trips fields and content`() { + val markdown = "# Episode notes\n\nSome **bold** text." + val template = + PodcastEpisodeEvent.build( + title = "Ep 1", + description = "First episode", + audios = listOf(AudioTag("https://example.com/ep1.mp3", "audio/mpeg")), + markdownContent = markdown, + image = "https://example.com/ep1.png", + ) + val event = signer.sign(template) + + assertEquals("Ep 1", event.title()) + assertEquals("First episode", event.description()) + assertEquals("https://example.com/ep1.png", event.image()) + assertEquals(markdown, event.content) + + val audios = event.audios() + assertEquals(1, audios.size) + assertEquals("https://example.com/ep1.mp3", audios[0].url) + assertEquals("audio/mpeg", audios[0].mediaType) + } + + @Test + fun `image is optional`() { + val template = + PodcastEpisodeEvent.build( + title = "Ep 2", + description = "No artwork", + audios = listOf(AudioTag("https://example.com/ep2.mp3")), + ) + val event = signer.sign(template) + + assertNull(event.image()) + } + + @Test + fun `multiple audio tags are preserved with optional media type`() { + val template = + PodcastEpisodeEvent.build( + title = "Ep 3", + description = "Multi-codec", + audios = + listOf( + AudioTag("https://example.com/ep3.mp3", "audio/mpeg"), + AudioTag("https://example.com/ep3.opus", "audio/opus"), + AudioTag("https://example.com/ep3.unknown"), + ), + ) + val event = signer.sign(template) + + val audios = event.audios() + assertEquals(3, audios.size) + assertEquals("audio/mpeg", audios[0].mediaType) + assertEquals("audio/opus", audios[1].mediaType) + assertNull(audios[2].mediaType) + } + + @Test + fun `alt tag includes title`() { + val template = + PodcastEpisodeEvent.build( + title = "Ep Title", + description = "x", + audios = listOf(AudioTag("https://example.com/x.mp3")), + ) + val event = signer.sign(template) + + val alt = event.tags.first { it[0] == "alt" } + assertTrue(alt[1].endsWith("Ep Title")) + assertTrue(alt[1].startsWith(PodcastEpisodeEvent.ALT_DESCRIPTION_PREFIX)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt new file mode 100644 index 0000000000..1d9bd5bf91 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt @@ -0,0 +1,136 @@ +/* + * 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.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastMetadataEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val authorPubKey1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val authorPubKey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + + @Test + fun `kind is 10154`() { + assertEquals(10154, PodcastMetadataEvent.KIND) + } + + @Test + fun `build round-trips required fields`() { + val template = + PodcastMetadataEvent.build( + title = "Show Title", + image = "https://example.com/cover.png", + description = "A show about things.", + ) + val event = signer.sign(template) + + assertEquals("Show Title", event.title()) + assertEquals("https://example.com/cover.png", event.image()) + assertEquals("A show about things.", event.description()) + assertTrue(event.websites().isEmpty()) + assertTrue(event.claimedAuthors().isEmpty()) + } + + @Test + fun `build round-trips multiple websites and authors with roles`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + websites = listOf("https://show.example", "https://show.example/feed"), + authors = + listOf( + AuthorTag(authorPubKey1, AuthorTag.ROLE_HOST), + AuthorTag(authorPubKey2, AuthorTag.ROLE_COHOST), + ), + ) + val event = signer.sign(template) + + assertEquals( + listOf("https://show.example", "https://show.example/feed"), + event.websites(), + ) + + val authors = event.claimedAuthors() + assertEquals(2, authors.size) + assertEquals(authorPubKey1, authors[0].pubKey) + assertEquals(AuthorTag.ROLE_HOST, authors[0].role) + assertEquals(authorPubKey2, authors[1].pubKey) + assertEquals(AuthorTag.ROLE_COHOST, authors[1].role) + } + + @Test + fun `author tag without role parses with null role`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + authors = listOf(AuthorTag(authorPubKey1, null)), + ) + val event = signer.sign(template) + + val authors = event.claimedAuthors() + assertEquals(1, authors.size) + assertEquals(authorPubKey1, authors[0].pubKey) + assertNull(authors[0].role) + } + + @Test + fun `alt tag is emitted`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + ) + val event = signer.sign(template) + + assertTrue(event.tags.any { it[0] == "alt" && it[1] == PodcastMetadataEvent.ALT_DESCRIPTION }) + } + + @Test + fun `is replaceable with empty d-tag`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + ) + val event = signer.sign(template) + + assertEquals("", event.dTag()) + assertEquals(10154, event.address().kind) + assertEquals(event.pubKey, event.address().pubKeyHex) + } +} From 09a46b7faca0177dfc15156b6966dc7e50550663 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 20:36:42 +0000 Subject: [PATCH 04/51] feat(amethyst): add Episodes & Podcasts screens for NIP-F4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing Music/Playlists screen pair end-to-end so podcast events flow through the standard feed pipeline: - AccountSettings/Account/LocalPreferences gain the two new follow-list selectors (defaultPodcastEpisodesFollowList, defaultPodcastsFollowList) plus their derived liveX/liveXPerRelay flows. - AccountFeedContentStates wires podcastEpisodesFeed (kind 54 from LocalCache.notes) and podcastsFeed (kind 10154 from addressables) into updateFeedsWith/deleteNotes. - RelaySubscriptionsCoordinator + BottomBarFeedPreloaders register the two new filter assemblers, each with its own EOSE/since cursor. - Routes/AppNavigation/NavBarItem add the two destinations; both go in DrawerFeedsItems with PlayCircle and AudioFile icons (existing subset glyphs, no font regeneration required). - New NoteCompose dispatch cases call RenderPodcastEpisode (cover + audio player via the shared GetMediaItem/GetVideoController/ RenderVoicePlayer chain + description + markdown content) and RenderPodcastMetadata (cover + title + description + website chips). Skipped (separate PRs): authoring flows (NewPodcast/NewEpisode) and the kind:10054 favorites toggle sheet — podcast publishers typically don't hold their podcast keypair in Amethyst, and favorites need a PrivateTagArrayEventCache hookup in Account that's larger than the read path alone. --- .../amethyst/LocalPreferences.kt | 10 + .../vitorpamplona/amethyst/model/Account.kt | 6 + .../amethyst/model/AccountSettings.kt | 24 ++ .../RelaySubscriptionsCoordinator.kt | 6 + .../ui/feeds/RememberForeverStates.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../ui/navigation/bottombars/NavBarItem.kt | 18 ++ .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/note/NoteCompose.kt | 12 + .../amethyst/ui/note/types/PodcastEpisode.kt | 242 ++++++++++++++++++ .../amethyst/ui/note/types/PodcastMetadata.kt | 157 ++++++++++++ .../loggedIn/AccountFeedContentStates.kt | 8 + .../loggedIn/BottomBarFeedPreloaders.kt | 6 + .../podcasts/PodcastEpisodesFeedLoaded.kt | 76 ++++++ .../podcasts/PodcastEpisodesScreen.kt | 135 ++++++++++ .../podcasts/PodcastEpisodesTopBar.kt | 84 ++++++ .../loggedIn/podcasts/PodcastsFeedLoaded.kt | 74 ++++++ .../loggedIn/podcasts/PodcastsScreen.kt | 135 ++++++++++ .../loggedIn/podcasts/PodcastsTopBar.kt | 82 ++++++ .../podcasts/dal/PodcastEpisodesFeedFilter.kt | 88 +++++++ .../podcasts/dal/PodcastsFeedFilter.kt | 87 +++++++ .../PodcastEpisodesFilterAssembler.kt | 50 ++++ ...castEpisodesFilterAssemblerSubscription.kt | 48 ++++ .../datasource/PodcastEpisodesSubAssembler.kt | 96 +++++++ .../datasource/PodcastsFilterAssembler.kt | 50 ++++ .../PodcastsFilterAssemblerSubscription.kt | 48 ++++ .../datasource/PodcastsSubAssembler.kt | 96 +++++++ .../podcasts/datasource/SubAssemblyHelper.kt | 73 ++++++ .../FilterPodcastEventsByAuthors.kt | 103 ++++++++ .../FilterPodcastEventsByCommunities.kt | 144 +++++++++++ .../FilterPodcastEventsByFollows.kt | 45 ++++ .../FilterPodcastEventsByGeohashes.kt | 72 ++++++ .../FilterPodcastEventsByHashtag.kt | 69 +++++ .../FilterPodcastEventsGlobal.kt | 50 ++++ amethyst/src/main/res/values/strings.xml | 2 + 35 files changed, 2206 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index f4c3d2de3d..a6f28f0db2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -114,6 +114,8 @@ private object PrefKeys { const val DEFAULT_ARTICLES_FOLLOW_LIST = "defaultArticlesFollowList" const val DEFAULT_MUSIC_TRACKS_FOLLOW_LIST = "defaultMusicTracksFollowList" const val DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST = "defaultMusicPlaylistsFollowList" + const val DEFAULT_PODCAST_EPISODES_FOLLOW_LIST = "defaultPodcastEpisodesFollowList" + const val DEFAULT_PODCASTS_FOLLOW_LIST = "defaultPodcastsFollowList" const val DEFAULT_SOFTWARE_APPS_FOLLOW_LIST = "defaultSoftwareAppsFollowList" const val DEFAULT_BADGES_FOLLOW_LIST = "defaultBadgesFollowList" const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" @@ -383,6 +385,8 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value)) putString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicTracksFollowList.value)) putString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicPlaylistsFollowList.value)) + putString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastEpisodesFollowList.value)) + putString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastsFollowList.value)) putString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultSoftwareAppsFollowList.value)) putString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value)) putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) @@ -667,6 +671,8 @@ object LocalPreferences { defaultLongsFollowList = MutableStateFlow(followListPrefs.longs), defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks), defaultMusicPlaylistsFollowList = MutableStateFlow(followListPrefs.musicPlaylists), + defaultPodcastEpisodesFollowList = MutableStateFlow(followListPrefs.podcastEpisodes), + defaultPodcastsFollowList = MutableStateFlow(followListPrefs.podcasts), defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles), defaultSoftwareAppsFollowList = MutableStateFlow(followListPrefs.softwareApps), defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges), @@ -743,6 +749,8 @@ object LocalPreferences { val articles: TopFilter, val musicTracks: TopFilter, val musicPlaylists: TopFilter, + val podcastEpisodes: TopFilter, + val podcasts: TopFilter, val softwareApps: TopFilter, val badges: TopFilter, val browseEmojiSets: TopFilter, @@ -768,6 +776,8 @@ object LocalPreferences { articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows), musicTracks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, null), TopFilter.Global), musicPlaylists = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, null), TopFilter.Global), + podcastEpisodes = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, null), TopFilter.Global), + podcasts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, null), TopFilter.Global), softwareApps = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, null), TopFilter.Global), badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine), browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), 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 7f4c37e962..5efba5b745 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -520,6 +520,12 @@ class Account( val liveMusicPlaylistsFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList) val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow + val livePodcastEpisodesFollowLists: StateFlow = topNavFilterFlow(settings.defaultPodcastEpisodesFollowList) + val livePodcastEpisodesFollowListsPerRelay = OutboxLoaderState(livePodcastEpisodesFollowLists, cache, scope).flow + + val livePodcastsFollowLists: StateFlow = topNavFilterFlow(settings.defaultPodcastsFollowList) + val livePodcastsFollowListsPerRelay = OutboxLoaderState(livePodcastsFollowLists, cache, scope).flow + val liveSoftwareAppsFollowLists: StateFlow = topNavFilterFlow(settings.defaultSoftwareAppsFollowList) val liveSoftwareAppsFollowListsPerRelay = OutboxLoaderState(liveSoftwareAppsFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 361df02d97..3d2ed1f85b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -173,6 +173,8 @@ class AccountSettings( val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultMusicTracksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultMusicPlaylistsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultPodcastEpisodesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultPodcastsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultSoftwareAppsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -652,6 +654,28 @@ class AccountSettings( } } + fun changeDefaultPodcastEpisodesFollowList(name: FeedDefinition) { + changeDefaultPodcastEpisodesFollowList(name.code) + } + + fun changeDefaultPodcastEpisodesFollowList(name: TopFilter) { + if (defaultPodcastEpisodesFollowList.value != name) { + defaultPodcastEpisodesFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultPodcastsFollowList(name: FeedDefinition) { + changeDefaultPodcastsFollowList(name.code) + } + + fun changeDefaultPodcastsFollowList(name: TopFilter) { + if (defaultPodcastsFollowList.value != name) { + defaultPodcastsFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) { changeDefaultSoftwareAppsFollowList(name.code) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 099218e5b3..54522be719 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -54,6 +54,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFi import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler @@ -127,6 +129,8 @@ class RelaySubscriptionsCoordinator( val articles = ArticlesFilterAssembler(client) val musicTracks = MusicTracksFilterAssembler(client) val musicPlaylists = MusicPlaylistsFilterAssembler(client) + val podcastEpisodes = PodcastEpisodesFilterAssembler(client) + val podcasts = PodcastsFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) @@ -162,6 +166,8 @@ class RelaySubscriptionsCoordinator( articles, musicTracks, musicPlaylists, + podcastEpisodes, + podcasts, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 15c288ba0a..c6d8f835d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -77,6 +77,8 @@ object ScrollStateKeys { const val ARTICLES_SCREEN = "ArticlesFeed" const val MUSIC_TRACKS_SCREEN = "MusicTracksFeed" const val MUSIC_PLAYLISTS_SCREEN = "MusicPlaylistsFeed" + const val PODCAST_EPISODES_SCREEN = "PodcastEpisodesFeed" + const val PODCASTS_SCREEN = "PodcastsFeed" const val SEARCH_SCREEN = "SearchFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a6848bdcf2..ca2bcaf2fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -147,6 +147,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -289,6 +291,8 @@ fun BuildNavigation( composableFromEnd { ArticlesScreen(accountViewModel, nav) } composableFromEnd { MusicTracksScreen(accountViewModel, nav) } composableFromEnd { MusicPlaylistsScreen(accountViewModel, nav) } + composableFromEnd { PodcastEpisodesScreen(accountViewModel, nav) } + composableFromEnd { PodcastsScreen(accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index bfdcc74dd7..2f48e8f2c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -56,6 +56,8 @@ enum class NavBarItem { SHORTS, MUSIC_TRACKS, MUSIC_PLAYLISTS, + PODCAST_EPISODES, + PODCASTS, PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, @@ -246,6 +248,20 @@ val NavBarCatalog: Map = icon = MaterialSymbols.AutoMirrored.PlaylistAdd, resolveRoute = { Route.MusicPlaylists }, ), + NavBarItem.PODCAST_EPISODES to + NavBarItemDef( + id = NavBarItem.PODCAST_EPISODES, + labelRes = R.string.route_podcast_episodes, + icon = MaterialSymbols.PlayCircle, + resolveRoute = { Route.PodcastEpisodes }, + ), + NavBarItem.PODCASTS to + NavBarItemDef( + id = NavBarItem.PODCASTS, + labelRes = R.string.route_podcasts, + icon = MaterialSymbols.AudioFile, + resolveRoute = { Route.Podcasts }, + ), NavBarItem.PUBLIC_CHATS to NavBarItemDef( id = NavBarItem.PUBLIC_CHATS, @@ -364,6 +380,8 @@ val DrawerFeedsItems: List = NavBarItem.SHORTS, NavBarItem.MUSIC_TRACKS, NavBarItem.MUSIC_PLAYLISTS, + NavBarItem.PODCAST_EPISODES, + NavBarItem.PODCASTS, NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 0c8ef1fe7b..71c0d2c778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -158,6 +158,10 @@ sealed class Route { @Serializable object MusicPlaylists : Route() + @Serializable object PodcastEpisodes : Route() + + @Serializable object Podcasts : Route() + @Serializable data class NewMusicTrack( val dTag: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 34cc57bfa8..e9ebff83ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -156,6 +156,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage @@ -315,6 +317,8 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -930,6 +934,14 @@ private fun RenderNoteRow( RenderMusicPlaylist(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is PodcastEpisodeEvent -> { + RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is PodcastMetadataEvent -> { + RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is DraftWrapEvent -> { RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt new file mode 100644 index 0000000000..def17de157 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -0,0 +1,242 @@ +/* + * 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.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent + +private val COVER_ASPECT_RATIO = 1f + +@Composable +fun RenderPodcastEpisode( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PodcastEpisodeEvent ?: return + + PodcastEpisodeBody( + noteEvent = noteEvent, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +private fun PodcastEpisodeBody( + noteEvent: PodcastEpisodeEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val description = remember(noteEvent) { noteEvent.description() } + // Pick the first audio URL. If a publisher provides multiple containers, this is the + // ordering they emitted — clients with codec preferences can extend this later. + val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } + // Suppress the markdown content block if it's blank (the title + description already + // describe a short episode); otherwise let RichText render it below. + val markdown = + remember(noteEvent) { + noteEvent.content.ifBlank { null } + } + + Column(MaterialTheme.colorScheme.replyModifier) { + PodcastEpisodeCover(image, note, accountViewModel) + firstAudio?.let { audio -> + // No waveform tag in NIP-F4, synthesize a flat baseline. ExoPlayer drives + // actual playback progress; the bars are purely decorative. + val waveform = remember(note.idHex) { flatWaveform() } + val callbackUri = remember(note) { note.toNostrUri() } + val playerBorder = + remember { + Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) + } + + Row( + Modifier.fillMaxWidth().height(80.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = audio.url, + title = title, + artworkUri = image, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + mimeType = audio.mediaType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), + keepPlaying = false, + waveformData = waveform, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = waveform, + borderModifier = playerBorder, + accountViewModel = accountViewModel, + ) + } + } + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + markdown?.takeIf { !makeItShort }?.let { + Spacer(Modifier.padding(top = 4.dp)) + val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } + val callbackUri = remember(note) { note.toNostrUri() } + + TranslatableRichTextViewer( + content = it, + canPreview = canPreview, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +private fun PodcastEpisodeCover( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) + val imageModifier = + Modifier + .fillMaxWidth() + .aspectRatio(COVER_ASPECT_RATIO) + .clip(imageShape) + + Box(imageModifier) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, + onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, imageModifier) + } + } +} + +private const val WAVEFORM_SAMPLES = 96 + +private fun flatWaveform(): WaveformData = + // Even baseline — purely decorative since NIP-F4 doesn't carry waveform data. + WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt new file mode 100644 index 0000000000..beb8bdbcbc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -0,0 +1,157 @@ +/* + * 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.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +private val COVER_ASPECT_RATIO = 1.0f + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderPodcastMetadata( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PodcastMetadataEvent ?: return + + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val description = remember(noteEvent) { noteEvent.description() } + val websites = remember(noteEvent) { noteEvent.websites() } + + Column(MaterialTheme.colorScheme.replyModifier) { + PodcastCover(image, note, accountViewModel) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + description?.takeIf { !makeItShort }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (websites.isNotEmpty() && !makeItShort) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + websites.forEach { website -> + Text( + text = website, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +@Composable +private fun PodcastCover( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) + val imageModifier = + Modifier + .fillMaxWidth() + .aspectRatio(COVER_ASPECT_RATIO) + .clip(imageShape) + + Box(imageModifier) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, + onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, imageModifier) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 340b5417fb..b72c382605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -59,6 +59,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal.PictureFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastEpisodesFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.ClosedPollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.OpenPollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter @@ -116,6 +118,8 @@ class AccountFeedContentStates( val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) val musicTracksFeed = FeedContentState(MusicTracksFeedFilter(account), scope, LocalCache) val musicPlaylistsFeed = FeedContentState(MusicPlaylistsFeedFilter(account), scope, LocalCache) + val podcastEpisodesFeed = FeedContentState(PodcastEpisodesFeedFilter(account), scope, LocalCache) + val podcastsFeed = FeedContentState(PodcastsFeedFilter(account), scope, LocalCache) val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) @@ -207,6 +211,8 @@ class AccountFeedContentStates( articlesFeed.updateFeedWith(newNotes) musicTracksFeed.updateFeedWith(newNotes) musicPlaylistsFeed.updateFeedWith(newNotes) + podcastEpisodesFeed.updateFeedWith(newNotes) + podcastsFeed.updateFeedWith(newNotes) softwareAppsFeed.updateFeedWith(newNotes) calendarAppointmentsFeed.updateFeedWith(newNotes) @@ -265,6 +271,8 @@ class AccountFeedContentStates( articlesFeed.deleteFromFeed(newNotes) musicTracksFeed.deleteFromFeed(newNotes) musicPlaylistsFeed.deleteFromFeed(newNotes) + podcastEpisodesFeed.deleteFromFeed(newNotes) + podcastsFeed.deleteFromFeed(newNotes) softwareAppsFeed.deleteFromFeed(newNotes) calendarAppointmentsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 2336e72399..02063940a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilte import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription @@ -102,6 +104,10 @@ private fun PreloadFor( // tabs no matter which one the user pinned. -> MusicTracksFilterAssemblerSubscription(accountViewModel) + NavBarItem.PODCAST_EPISODES -> PodcastEpisodesFilterAssemblerSubscription(accountViewModel) + + NavBarItem.PODCASTS -> PodcastsFilterAssemblerSubscription(accountViewModel) + NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel) NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt new file mode 100644 index 0000000000..4abe4c7a9c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt @@ -0,0 +1,76 @@ +/* + * 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.podcasts + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun PodcastEpisodesFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + // NoteCompose wraps the episode card with the standard reactions row + author chrome + // so zap/reply/etc. work the same as on the home feed. + NoteCompose( + baseNote = item, + modifier = Modifier, + isBoostedNote = false, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt new file mode 100644 index 0000000000..940282b58f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt @@ -0,0 +1,135 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastEpisodesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PodcastEpisodesScreen( + episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PodcastEpisodesScreen( + episodesFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(episodesFeedContentState) + WatchAccountForPodcastEpisodesScreen( + episodesFeedState = episodesFeedContentState, + accountViewModel = accountViewModel, + ) + // Drives the kind:54 REQ for the user's selected follow list — without it the screen + // only shows episodes already in cache from some other path (e.g. a nostr: link). + PodcastEpisodesFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PodcastEpisodesTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.PodcastEpisodes, nav, accountViewModel) { route -> + if (route == Route.PodcastEpisodes) { + episodesFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(episodesFeedContentState, true) { + SaveableFeedContentState(episodesFeedContentState, scrollStateKey = ScrollStateKeys.PODCAST_EPISODES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = episodesFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PodcastEpisodesFeed", + onLoaded = { loaded -> + PodcastEpisodesFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForPodcastEpisodesScreen( + episodesFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.livePodcastEpisodesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + episodesFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun PodcastEpisodesScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + PodcastEpisodesScreen( + episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt new file mode 100644 index 0000000000..ca40ecb546 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt @@ -0,0 +1,84 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastEpisodesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultPodcastEpisodesFollowList + .collectAsStateWithLifecycle() + + PodcastEpisodesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultPodcastEpisodesFollowList, + ) + } +} + +@Composable +private fun PodcastEpisodesTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + // Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3 + // Follows, Around Me, Global, people lists, interest sets, mute list. + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = onChange, + accountViewModel = accountViewModel, + ) +} + +@Preview +@Composable +private fun PodcastEpisodesTopBarPreview() { + ThemeComparisonColumn { + PodcastEpisodesTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt new file mode 100644 index 0000000000..55389e62f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt @@ -0,0 +1,74 @@ +/* + * 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.podcasts + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun PodcastsFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + NoteCompose( + baseNote = item, + modifier = Modifier, + isBoostedNote = false, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt new file mode 100644 index 0000000000..8762972787 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -0,0 +1,135 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PodcastsScreen( + podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PodcastsScreen( + podcastsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(podcastsFeedContentState) + WatchAccountForPodcastsScreen( + podcastsFeedState = podcastsFeedContentState, + accountViewModel = accountViewModel, + ) + // Dedicated REQ for kind 10154 only — episode events come down through the episodes + // screen's own subscription, keeping each screen's `since` cursor isolated. + PodcastsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PodcastsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.Podcasts, nav, accountViewModel) { route -> + if (route == Route.Podcasts) { + podcastsFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(podcastsFeedContentState, true) { + SaveableFeedContentState(podcastsFeedContentState, scrollStateKey = ScrollStateKeys.PODCASTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = podcastsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PodcastsFeed", + onLoaded = { loaded -> + PodcastsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForPodcastsScreen( + podcastsFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.livePodcastsFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + podcastsFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun PodcastsScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + PodcastsScreen( + podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt new file mode 100644 index 0000000000..28dcbeb002 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt @@ -0,0 +1,82 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultPodcastsFollowList + .collectAsStateWithLifecycle() + + PodcastsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultPodcastsFollowList, + ) + } +} + +@Composable +private fun PodcastsTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = onChange, + accountViewModel = accountViewModel, + ) +} + +@Preview +@Composable +private fun PodcastsTopBarPreview() { + ThemeComparisonColumn { + PodcastsTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt new file mode 100644 index 0000000000..2a41a0e13a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -0,0 +1,88 @@ +/* + * 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.podcasts.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent + +/** + * Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike + * music tracks/playlists which are addressable. Same follow-list + hidden/blocked + * gate as MusicTracksFeedFilter. + */ +class PodcastEpisodesFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "podcast-episodes-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultPodcastEpisodesFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.notes.filterIntoSet { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePodcastEpisodesFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } + + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + return noteEvent is PodcastEpisodeEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt new file mode 100644 index 0000000000..742951b511 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt @@ -0,0 +1,87 @@ +/* + * 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.podcasts.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in + * `LocalCache.addressables` — same shape as the music-playlists filter. + */ +class PodcastsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "podcasts-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultPodcastsFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePodcastsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } + + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + return noteEvent is PodcastMetadataEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt new file mode 100644 index 0000000000..19248cc6b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * 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.podcasts.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PodcastEpisodesQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PodcastEpisodesFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + PodcastEpisodesSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..5d0ab8998c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * 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.podcasts.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PodcastEpisodesFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PodcastEpisodesFilterAssemblerSubscription( + accountViewModel.dataSources().podcastEpisodes, + accountViewModel, + ) +} + +@Composable +fun PodcastEpisodesFilterAssemblerSubscription( + dataSource: PodcastEpisodesFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PodcastEpisodesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt new file mode 100644 index 0000000000..87eeb1a3ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt @@ -0,0 +1,96 @@ +/* + * 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.podcasts.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PodcastEpisodesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PodcastEpisodesQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makePodcastEpisodesFilter(feedSettings, since, key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PodcastEpisodesQueryState) = key.account.userProfile() + + override fun list(key: PodcastEpisodesQueryState) = key.listName() + + fun PodcastEpisodesQueryState.listNameFlow() = account.settings.defaultPodcastEpisodesFollowList + + fun PodcastEpisodesQueryState.listName() = listNameFlow().value + + fun PodcastEpisodesQueryState.followsPerRelayFlow() = account.livePodcastEpisodesFollowListsPerRelay + + fun PodcastEpisodesQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PodcastEpisodesQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt new file mode 100644 index 0000000000..84b70f1989 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * 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.podcasts.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PodcastsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PodcastsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + PodcastsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..4b8c44ab92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * 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.podcasts.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PodcastsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PodcastsFilterAssemblerSubscription( + accountViewModel.dataSources().podcasts, + accountViewModel, + ) +} + +@Composable +fun PodcastsFilterAssemblerSubscription( + dataSource: PodcastsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PodcastsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt new file mode 100644 index 0000000000..579e4cae88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt @@ -0,0 +1,96 @@ +/* + * 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.podcasts.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PodcastsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PodcastsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makePodcastsFilter(feedSettings, since, key.feedStates.podcastsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PodcastsQueryState) = key.account.userProfile() + + override fun list(key: PodcastsQueryState) = key.listName() + + fun PodcastsQueryState.listNameFlow() = account.settings.defaultPodcastsFollowList + + fun PodcastsQueryState.listName() = listNameFlow().value + + fun PodcastsQueryState.followsPerRelayFlow() = account.livePodcastsFollowListsPerRelay + + fun PodcastsQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PodcastsQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.podcastsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..19b65c62aa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -0,0 +1,73 @@ +/* + * 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.podcasts.datasource + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePodcastEpisodesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makePodcastFilter(feedSettings, PODCAST_EPISODE_KINDS, since, defaultSince) + +fun makePodcastsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) + +private fun makePodcastFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt new file mode 100644 index 0000000000..0feba30fba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.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.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +// Each podcast screen issues its own REQ keyed on its own follow-list selector — keep +// the kind lists split so episodes and show metadata never co-mingle in one filter. +internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND) +internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) + +fun filterPodcastEventsByAuthors( + relay: NormalizedRelayUrl, + kinds: List, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = kinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPodcastEventsByAuthors( + relay = it.key, + kinds = kinds, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPodcastEventsByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPodcastEventsByAuthors( + relay = it.key, + kinds = kinds, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt new file mode 100644 index 0000000000..697f3abf19 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -0,0 +1,144 @@ +/* + * 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.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPodcastEventsFromAllCommunities( + relay: NormalizedRelayUrl, + kinds: List, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + val kindsAsStrings = kinds.map { it.toString() } + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to kindsAsStrings, + ), + limit = communityList.size * 20, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("a" to communityList), + limit = communityList.size * 20, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPodcastEventsFromAllCommunities( + relay = it.key, + kinds = kinds, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} + +fun filterPodcastEventsFromCommunity( + relay: NormalizedRelayUrl, + kinds: List, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authorList = authors?.sorted() + val kindsAsStrings = kinds.map { it.toString() } + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to kindsAsStrings, + ), + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = kinds, + tags = mapOf("a" to listOf(community)), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set + .mapNotNull { + filterPodcastEventsFromCommunity( + relay = it.key, + kinds = kinds, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt new file mode 100644 index 0000000000..0b573739d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt @@ -0,0 +1,45 @@ +/* + * 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.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPodcastEventsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { authors -> + filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt new file mode 100644 index 0000000000..f746865b0d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -0,0 +1,72 @@ +/* + * 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.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPodcastEventsByGeohashes( + relay: NormalizedRelayUrl, + kinds: List, + geotags: Set, + since: Long?, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPodcastEventsByGeohashes( + relay = it.key, + kinds = kinds, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt new file mode 100644 index 0000000000..124c464a17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -0,0 +1,69 @@ +/* + * 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.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPodcastEventsByHashtag( + relay: NormalizedRelayUrl, + kinds: List, + hashtags: Set, + since: Long? = null, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("t" to hashtags.sorted()), + limit = 200, + since = since, + ), + ), + ) + +fun filterPodcastEventsByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { + if (it.value.hashtags.isEmpty()) { + null + } else { + filterPodcastEventsByHashtag( + relay = it.key, + kinds = kinds, + hashtags = it.value.hashtags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt new file mode 100644 index 0000000000..e0fc0c4cba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -0,0 +1,50 @@ +/* + * 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.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterPodcastEventsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + // Podcasts are sparse on most relays today, so no `oneWeekAgo` floor — let the relay's + // own limit + kind filter bound the response. Mirrors the music-global rationale. + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = kinds, + limit = 200, + since = sinceForRelay, + ), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e5665979e9..c06d3b6217 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -764,6 +764,8 @@ Articles Music Playlists + Episodes + Podcasts New music track New playlist Title From df0bb2641a7828b5e578b2a9c54bf2d0477e98d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 20:56:00 +0000 Subject: [PATCH 05/51] chore(commons): use Headphones + Podcasts glyphs for the podcast tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps PlayCircle / AudioFile (generic) for the canonical Material Symbols podcast iconography — `headphones` (U+F01F) on the Episodes feed and `podcasts` (U+F048, the mic + signal-waves glyph) on the Shows feed. Both codepoints added to MaterialSymbols.kt and the subset font regenerated via tools/material-symbols-subset/subset.sh. --- .../ui/navigation/bottombars/NavBarItem.kt | 4 ++-- .../font/material_symbols_outlined.ttf | Bin 431188 -> 435080 bytes .../commons/icons/symbols/MaterialSymbols.kt | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 2f48e8f2c9..01dc584878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -252,14 +252,14 @@ val NavBarCatalog: Map = NavBarItemDef( id = NavBarItem.PODCAST_EPISODES, labelRes = R.string.route_podcast_episodes, - icon = MaterialSymbols.PlayCircle, + icon = MaterialSymbols.Headphones, resolveRoute = { Route.PodcastEpisodes }, ), NavBarItem.PODCASTS to NavBarItemDef( id = NavBarItem.PODCASTS, labelRes = R.string.route_podcasts, - icon = MaterialSymbols.AudioFile, + icon = MaterialSymbols.Podcasts, resolveRoute = { Route.Podcasts }, ), NavBarItem.PUBLIC_CHATS to diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 034b0d82b14b2499a5cdb7253257f22e21f9375b..f8902f46e3d0d565bbb138b9204bcdf565d42a4e 100644 GIT binary patch delta 5429 zcmaJ@4Rlk-mA>=dydPPzCCic}%R-W|jctBnjEyl#_%oRQfWSabNJ=n&p#+G}LQ}}H z5w`eA1A&|>X?Oc`%I1f|wlo3U0LSTO(@i$*X5EBPlO`u=YA29&T(2VwNtU19llION zHpYkDk>=gGcV_O~xpTie(|N}A%&uei>z#{oHlROqjLe;upc%yu9>&yXn60B0Y0`M|8CQhO@9ZbZ_WYy`EBI= zn|C!eU!C%+`*3^|KwP>F`;Xn+^%{=v#Qwg=n)U>mv4N96I*wF+Z0G)MJC1+W{HXw+SU~u%C%12H+A`z5RSWi? zL4)7h`NZa?qmd01;MY38t7%^|IZU=7{|t`z?QZhy+PZ#e%ew&Wa{+i$^AmgazTP=) zGr+$t2e8d)bCZT(K!AO)k6+9$1`m|NWT=LF0ZNO9cW`nE?8 z^-BRi`1xkO8M2`Q=HW(jCrp@LQ#&o+ZWE>4QpU{Y*q)IstF6T?`$W4f-!WsH&#b$rwmlVXudS>ro_PEZ$0rugEEu=p(1vjZ z{@G-ivDi^mSy|*L$x6Mkd&Z0F8gUBFY+U!F+Pca68;-QMA8FV(xvuEp>06E;-!lCX z-%Q8s;`$96>Pu!h+Lj)F(QCkfCY#7+7#}+EX8wh<)m)&r-ci4z{=4 z{V|#2-$n_1UG@FF8TyMC+pfR1gd_Vx6VF$L9y-5~K)b}vO-X8CDpUu16`)|JeA^#f% zwn7^~p}BAz)8W!wNmozd zD%T>H%T?(5mx3YJbeF$CDfmUf!=Vq}sLX41?scS{0mnOzLI6k9G2b~YWVu*eIh21s zU&((g|M&Uj_WgFht;g15f688EJCkOUtI2T{$lS z7{4%Xly>TFV?#y?S&#l$Pd0?kUn~l}eNkFS(Dbo|l#_{LWjM`~skeIwZW3iy;%4f+ z0P09z9F$RQfH)*@jDuXqZ$b74BYgnx-7-ZqyacbH9Bb&`FU`oVAd_Gd>b4ON>K8BH zSGf|-!D8%x3H}P7lU1YALQ%=NIDH>^e%xMD2Ez!!n)9f5AP#KLj50pILk@gXmk-NCZ(i| z;6}(4Qb}q_9l4jxB6G+*GM_Y%MQHjm@&H-MwnEmD2gyU^Ve*|&;!0Jh?4t<=v=obz zY-(Hg(OaBM9wi$|6Q*!mr;#(9BAeLZird@DKi)k*(QP62(P4(LBopr;lBJ$)SL>t6%C&(K}4}$SJ2zdz*@-Ktn-2%dRlu!8tglQ=dX8$7y3$}x>@+t`H zOdvF!17T|q2#<>(>^cj=ew01!0^#s0AUv}egp===gYW_he|7eNLoySnf06coC_LRDk<^M%2M$pABmc7=0F`Rve#=)FcX1 z2;~Vqd~jZRV|rueTj(MIo^XUG?89+{6^;|wj|eW=YBd|x*g$`Of+i9wm1R{Hm4qlO z371s{>==lFY*j$TUQn=~vf?U0g{VvakJ3s6PAX8kx^DCj0PWN})2 zU16W4D{OT-Z5HV10UVHkFeJcYIaJ`(^+>p3T@m2|BbrEG?1Pv!S7@tf^HEjp>TBgG z_1RiMZRPs9)L=diFZ10ReeKH_Fb#p4&d$R^XQwQ)k#eiW=9BL%L9a(QS~q#Pb%zf; zro(Fekgay$}WOfZ03{7{GG) zARZe~3=Yb1z;Q?|7W5a=a2W9a)~_)D5+-LWb(*jd#14-KJRUrk(eX3-T-3qAgu;ok z!9Y_qrE;pqQ%&WmV^9T@$|9(PJSG9)Q>vO`1>=Sl{#M9fII;+0k%)1_8!Q%!LAx0a zhX=JRO~YYvp4T*9D=?d7E*%NS)0}KJ$J1#oosRPuBRubPppHysDxDBB?CPcLC77;eVY&+BS?>rTJp?%GT~d$rE-8n3kU2pAqYE;# z^Ts9_mSghnmR&67fIekZVlj_RDCV(=gzM^yoiJgh@Zp(bvgqsU3btaNcoy?Ss{u2P zjAG0@!T&2qz?Iv%Aiz?`a57-IfOK{SJ?;@iXJD&C`g$}PO$sKH$sI&Qk3f3-Fs8e` zUP$5)@Opz5^BrDA*O)-nN-!HQMK$oM-!V!Z<# z8Ejm->JeUF5ZHr}{R2!0PvgUA6-M@hctIcV5eQ&pw_;=;z{qaJ$bJBfupcA4v;-H# zD;M`A3T$x(SAB~p#8zh4q(l^jR5GbjA)Qnaq0AngO=VbKLryEt~O)N(2Jt&DT2K>G(@_am}onNp`O(+TGpT z_372Td&+#qY_l?OGgkJ_jh;d8;5Rp}Wkwa>{Nmc*OkS@^>FxR&?uq|*S1c}?wRo(e z{MO>^3SwKqW%c!0oFWieh+<*ZWd$@1qmJkCqE5Gn?7m8CsTM2VL>!9rq>*NnUH3=rO>mtOvn&s0vo`5%mpe$Ns{oa zl%XL(lmwbeDvBxylEw)3A-%<`XmJZsl$Ii{T;PeCR8+zXNtKrnF#^wN7`nWmY9OUm z-TzEKdmyk)>kBf6vqCd0^FV;$tAlxfZ8zQG(&wNH*v`i|fW*@+hT9HkF|iG&Q~0n- zrPCw(2eyASL{E6Qke(x$H!^ipi+Pa*DkND(L=1|uKoW@|d@^V7f-US}$kAIGl8P#- zC4f*dHIx93ii(FwVOwI{@XnB*fZ0LA#9%a= zsX8za!*>cVA+_3ld1m;oYP-u delta 1486 zcmZ8f2~3n%5dPlsevTlr=)%IkOIZsjl_-Z*h*A{TV$~wL+Fd~?RbvxV6~ro3p%jg!hA1M64Y78xSK7%xGw;pJoB3w`+3}rt z;hBK<5@>e0H?wL+l4vg-}R)RiQ;?6Kb&h? zTTw&@ITierw0&;=`kV{r4g^qfHE{HNEz@zzQ};c8Yh-F9kx!#iqH3eswQ{XNyH$Ho+pKNV_G*W9b~;g~(=F4L>bC0k>+VM{i*Ai! zF>_;zV(MbwirpPM5tkj;7%z{{j&F${Oh`(oN*GL(C%&I}EXhAiR!XPd zUZ1GnpdU+(P2HQ?o;E$rnAU0V4mOk-tj0OU8e{KL%~I2=A1NDYi2345OQv(!Q+t_B z2la^ivztEtQ}@@I_ch1Kq@PdCm0Zj#rDF3z$C=bl4)tV*9JAZ$0A{mzB*L6{W;L@3 zABm8xUx&@n_f$Rk7AV+ojE;a;qK>Ap#ML8$_iW6CJ^0IAdB?f=Z}q}#Y#uG>X7KiBoHdc}}p z$aS4-kYbmMv;2xnw#yxP_GEs*`L6W3)z=}wSvj>HaJ(()txCIe>NuS(mx`^vaTLSe zd=>g1!F(9a<^RM)iYES-7%eW@rZRWR=Iu!<_ZN-g+22Qu(rFZ96rp4TBt4ui|KkJB zPvW4ps>A1~mv){Fahk>6z`JZ3Q!q6XrK@Kb1tg*wv1mX&e!?A=%=DOt2P~RpAP8YB z9Yrh*jZmY9d83zwn?qYZVJuL((R!HVKI?INYj4Yx z1Rm_=OE^zzOobn48q6rwcP(O7Qh(RAMIxc_Vl$XG^JRpL`Lj7}9-GfX*g_V@!r2lQ z$+Uzemc_HgNo*{Q8EL*5G~ox=gCx&xABW6W{kju`_-=wAzv(D**vB%dB5dC=`}E}S z3T-2JbUt{3EqGD~xN!@3#wGBTW8kauz;lDa3&~be4_>wzd|fU0`W)~LGVo0^!9SP= zUO{?`8hm>?cqQqoYT{AwnknGBdcY+S{6H3XeGB+uE%>oc@FvRHb_o^yJQcbe2;SWf ze(O2-Ai#g2phuzLj~l=z=7Rrjg<$(V1cwhHObdnJ+5kaw9D?t42toM}Lc$L Date: Thu, 28 May 2026 21:05:58 +0000 Subject: [PATCH 06/51] =?UTF-8?q?feat(quartz):=20NIP-78=20=E2=80=94=20add?= =?UTF-8?q?=20kind=2078=20normal=20app=20data=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-78 was updated (nostr-protocol/nips#2292) to define a second event kind alongside the existing addressable kind 30078: - Kind 78: normal event, for apps that need to store and query multiple events of the same type. Recommended to use unique tags (including `d` tags) for grouping related events; the `d` tag here is a grouping key only, not an addressing key. Add `AppDataEvent` (kind 78) extending `Event`, mirroring the ergonomics of `AppSpecificDataEvent` (kind 30078): optional `d` tag hoisted into `tags`, NIP-31 `alt` tag injected when absent, and a `signer.sign(...)` factory. Register it in `EventFactory` so incoming kind-78 events deserialize into the typed class. The existing kind-30078 implementation remains compliant with the updated spec. --- .../quartz/nip78AppData/AppDataEvent.kt | 81 +++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 2 files changed, 83 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt new file mode 100644 index 0000000000..d5def1406d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt @@ -0,0 +1,81 @@ +/* + * 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.quartz.nip78AppData + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-78 — Arbitrary custom app data, normal-event flavor. + * + * Kind 78 is the non-replaceable counterpart to [AppSpecificDataEvent] (kind + * 30078). Use it when an app needs to store and query multiple events of the + * same type. The spec recommends using unique tags (including `d` tags) for + * grouping related events; the `d` tag here does NOT make the event + * addressable, it is just a free-form grouping key. + */ +@Immutable +class AppDataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun dTag(): String = tags.dTag() + + companion object { + const val KIND = 78 + const val ALT = "Arbitrary app data" + + suspend fun create( + content: String, + dTag: String? = null, + otherTags: Array> = emptyArray(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): AppDataEvent { + val withD = + if (dTag == null) { + otherTags + } else if (otherTags.any { it.size > 1 && it[0] == "d" && it[1] == dTag }) { + otherTags + } else { + otherTags.filter { it.isEmpty() || it[0] != "d" }.toTypedArray() + arrayOf(arrayOf("d", dTag)) + } + + val newTags = + if (withD.none { it.isNotEmpty() && it[0] == "alt" }) { + withD + arrayOf(AltTag.assemble(ALT)) + } else { + withD + } + + return signer.sign(createdAt, KIND, newTags, content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 08ffa2caef..52f57e5c22 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -218,6 +218,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -322,6 +323,7 @@ class EventFactory { AppCurationSetEvent.KIND -> AppCurationSetEvent(id, pubKey, createdAt, tags, content, sig) AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) AppRecommendationEvent.KIND -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AppDataEvent.KIND -> AppDataEvent(id, pubKey, createdAt, tags, content, sig) AppSpecificDataEvent.KIND -> AppSpecificDataEvent(id, pubKey, createdAt, tags, content, sig) AttestationEvent.KIND -> AttestationEvent(id, pubKey, createdAt, tags, content, sig) AttestationRequestEvent.KIND -> AttestationRequestEvent(id, pubKey, createdAt, tags, content, sig) From 7efe6fdf2a7e9270cf1b88c9c2aa70bffa7c1461 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 21:15:08 +0000 Subject: [PATCH 07/51] feat(nip71): support audio-track imeta variants per nostr-protocol/nips#2255 Adds the audio-track imeta properties from NIP-71 PR #2255 so video events can advertise external audio tracks (multi-language, alternate bitrates) alongside video variants: - New imeta properties: bitrate, duration (float seconds), waveform, and l [ov] for language with an original-version flag - Extends VideoMeta with bitrate, duration, waveform, language fields plus isAudio/isVideo helpers - VideoEvent exposes audioTracks()/videoTracks() so players can prefer separate audio tracks over in-video audio while switching resolution - Round-trip test against the PR's spec example --- .../quartz/nip71Video/IMetaTagBuilderExt.kt | 20 +++ .../quartz/nip71Video/IMetaTagExt.kt | 19 +++ .../quartz/nip71Video/VideoEvent.kt | 13 ++ .../quartz/nip71Video/VideoMeta.kt | 20 +++ .../quartz/nip71Video/tags/BitrateTag.kt | 29 ++++ .../nip71Video/tags/LanguageImetaTag.kt | 65 +++++++++ .../quartz/nip71Video/VideoAudioImetaTest.kt | 129 ++++++++++++++++++ 7 files changed, 295 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt index 06672d7b49..4fc6694b63 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nip71Video import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -38,6 +41,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag /** * Contains the IMeta tags that are used by Video events. @@ -73,3 +77,19 @@ fun IMetaTagBuilder.summary(summary: HexKey) = add(SummaryTag.TAG_NAME, summary) fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallback) fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service) + +fun IMetaTagBuilder.bitrate(bitsPerSecond: Int) = add(BitrateTag.TAG_NAME, bitsPerSecond.toString()) + +fun IMetaTagBuilder.duration(seconds: Double) = add(DurationTag.TAG_NAME, seconds.toString()) + +fun IMetaTagBuilder.duration(seconds: Int) = add(DurationTag.TAG_NAME, seconds.toString()) + +fun IMetaTagBuilder.waveform(wave: List) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWaveFloat(wave)) + +fun IMetaTagBuilder.language(language: LanguageImetaTag) = add(LanguageImetaTag.TAG_NAME, language.toPropertyValue()) + +fun IMetaTagBuilder.language( + code: String, + standard: String = LanguageImetaTag.DEFAULT_STANDARD, + originalVersion: Boolean = false, +) = language(LanguageImetaTag(code, standard, originalVersion)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt index 42d0e2addd..714dfc7d97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.nip71Video import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -37,6 +40,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag /** * Contains the IMeta tags that are used by Video events. @@ -72,3 +76,18 @@ fun IMetaTag.summary() = properties.get(SummaryTag.TAG_NAME) fun IMetaTag.fallback() = properties.get(FallbackTag.TAG_NAME) fun IMetaTag.service() = properties.get(ServiceTag.TAG_NAME) + +fun IMetaTag.bitrate(): Int? = properties.get(BitrateTag.TAG_NAME)?.firstOrNull()?.toIntOrNull() + +fun IMetaTag.duration(): Double? = properties.get(DurationTag.TAG_NAME)?.firstOrNull()?.toDoubleOrNull() + +fun IMetaTag.waveform(): List? = properties.get(WaveformTag.TAG_NAME)?.firstOrNull()?.let { WaveformTag.parseWave(it) } + +fun IMetaTag.language(): LanguageImetaTag? = properties.get(LanguageImetaTag.TAG_NAME)?.firstNotNullOfOrNull { LanguageImetaTag.parseValue(it) } + +// NIP-71 PR #2255: audio-only variants are advertised as `imeta` entries +// with an `m audio/...` property so clients can prefer them over in-video +// audio while switching video resolution. +fun IMetaTag.isAudioTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("audio/") == true + +fun IMetaTag.isVideoTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("video/") == true diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index efd1c8c032..f0003229f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -47,4 +47,17 @@ interface VideoEvent : IEvent { fun hash(): String? fun imetaTags(): List + + /** + * NIP-71 PR #2255: imeta entries whose mime type starts with `video/` + * (or entries without a mime type, treated as legacy video variants). + */ + fun videoTracks(): List = imetaTags().filter { it.isVideo || it.mimeType == null } + + /** + * NIP-71 PR #2255: imeta entries whose mime type starts with `audio/`. + * Clients should prefer these over in-video audio to enable smooth + * resolution switching without interrupting audio. + */ + fun audioTracks(): List = imetaTags().filter { it.isAudio } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt index 66d4d334c0..967d3cf114 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip71Video +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -36,7 +37,18 @@ data class VideoMeta( val fallback: List = emptyList(), val image: List = emptyList(), val thumbhash: String? = null, + // NIP-71 PR #2255 — audio-track imeta properties + val bitrate: Int? = null, + val duration: Double? = null, + val waveform: List? = null, + val language: LanguageImetaTag? = null, ) { + val isAudio: Boolean + get() = mimeType?.startsWith("audio/") == true + + val isVideo: Boolean + get() = mimeType?.startsWith("video/") == true + fun toIMetaArray(): Array = IMetaTagBuilder(url) .apply { @@ -50,6 +62,10 @@ data class VideoMeta( service?.let { service(it) } fallback.forEach { fallback(it) } image.forEach { image(it) } + bitrate?.let { bitrate(it) } + duration?.let { duration(it) } + waveform?.let { waveform(it) } + language?.let { language(it) } }.build() .toTagArray() @@ -67,6 +83,10 @@ data class VideoMeta( fallback = iMeta.fallback() ?: emptyList(), image = iMeta.image() ?: emptyList(), thumbhash = iMeta.thumbhash()?.firstOrNull(), + bitrate = iMeta.bitrate(), + duration = iMeta.duration(), + waveform = iMeta.waveform(), + language = iMeta.language(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt new file mode 100644 index 0000000000..1b82711f9a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt @@ -0,0 +1,29 @@ +/* + * 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.quartz.nip71Video.tags + +class BitrateTag { + companion object { + const val TAG_NAME = "bitrate" + + fun assemble(bitsPerSecond: Int) = arrayOf(TAG_NAME, bitsPerSecond.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt new file mode 100644 index 0000000000..2926e1c85f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt @@ -0,0 +1,65 @@ +/* + * 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.quartz.nip71Video.tags + +import androidx.compose.runtime.Immutable + +/** + * NIP-71 audio-track language tag (lives inside an `imeta` tag). + * + * Format: `l [ov]` + * + * - `code` — language code, e.g. `en` + * - `standard` — namespace, e.g. `ISO-639-1` (default when missing) + * - `ov` — optional flag marking the original-version track + */ +@Immutable +data class LanguageImetaTag( + val code: String, + val standard: String = DEFAULT_STANDARD, + val originalVersion: Boolean = false, +) { + fun toPropertyValue(): String = + buildString { + append(code) + append(' ') + append(standard) + if (originalVersion) { + append(' ') + append(ORIGINAL_VERSION_MARKER) + } + } + + companion object { + const val TAG_NAME = "l" + const val DEFAULT_STANDARD = "ISO-639-1" + const val ORIGINAL_VERSION_MARKER = "ov" + + fun parseValue(value: String): LanguageImetaTag? { + val parts = value.split(' ').filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + val code = parts[0] + val standard = parts.getOrNull(1) ?: DEFAULT_STANDARD + val ov = parts.drop(2).any { it == ORIGINAL_VERSION_MARKER } + return LanguageImetaTag(code, standard, ov) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt new file mode 100644 index 0000000000..1826bf65ab --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt @@ -0,0 +1,129 @@ +/* + * 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.quartz.nip71Video + +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class VideoAudioImetaTest { + /** + * NIP-71 PR #2255 spec example for an audio-only imeta variant. + */ + @Test + fun audioImetaRoundTripMatchesSpecExample() { + val waveform = + listOf( + 0f, + 7f, + 35f, + 8f, + 100f, + 100f, + 49f, + 8f, + 4f, + 16f, + 8f, + 10f, + 7f, + 2f, + 20f, + 10f, + 100f, + 100f, + 100f, + 100f, + ) + + val original = + VideoMeta( + url = "https://myaudio.com/audio/en/12345.mp3", + mimeType = "audio/mp3", + hash = "b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93", + service = "nip96", + fallback = + listOf( + "https://myotherserver.com/audio/en/12345.mp3", + "https://andanotherserver.com/audio/en/12345.mp3", + ), + bitrate = 320000, + duration = 29.24, + waveform = waveform, + language = LanguageImetaTag("en", "ISO-639-1", originalVersion = true), + ) + + val tag = original.toIMetaArray() + val parsed = IMetaTag.parse(tag)?.firstOrNull() + assertNotNull(parsed) + val round = VideoMeta.parse(parsed) + + assertEquals(original.url, round.url) + assertEquals("audio/mp3", round.mimeType) + assertEquals(original.hash, round.hash) + assertEquals("nip96", round.service) + assertEquals(original.fallback, round.fallback) + assertEquals(320000, round.bitrate) + assertEquals(29.24, round.duration) + assertEquals(waveform, round.waveform) + assertEquals(LanguageImetaTag("en", "ISO-639-1", originalVersion = true), round.language) + assertTrue(round.isAudio) + } + + @Test + fun audioAndVideoTracksAreSplitByMimeType() { + val video = + VideoMeta( + url = "https://example.com/video.mp4", + mimeType = "video/mp4", + ) + val audio = + VideoMeta( + url = "https://example.com/audio.mp3", + mimeType = "audio/mp3", + language = LanguageImetaTag("en"), + ) + + assertTrue(video.isVideo) + assertTrue(audio.isAudio) + } + + @Test + fun languageImetaTagParsesOriginalVersionFlag() { + val parsed = LanguageImetaTag.parseValue("en ISO-639-1 ov") + assertNotNull(parsed) + assertEquals("en", parsed.code) + assertEquals("ISO-639-1", parsed.standard) + assertTrue(parsed.originalVersion) + } + + @Test + fun languageImetaTagDefaultsStandardWhenMissing() { + val parsed = LanguageImetaTag.parseValue("pt") + assertNotNull(parsed) + assertEquals("pt", parsed.code) + assertEquals("ISO-639-1", parsed.standard) + assertEquals(false, parsed.originalVersion) + } +} From 40ed26ea8524ce2583e01941def400fafc73a73f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 21:16:03 +0000 Subject: [PATCH 08/51] refactor(quartz): NIP-78 events use the build-template pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert both kind 30078 and the new kind 78 from the legacy `suspend create(... signer)` shape to the now-standard `build(...) -> EventTemplate` shape used across recent quartz events (NIP-34, NIP-66, etc): - Use `eventTemplate(KIND, content, createdAt) { ... }` and lean on the shared `alt()` and `dTag()` TagArrayBuilder extensions instead of hand-rolling the `d`/`alt` injection. - Callers now do `signer.sign(AppSpecificDataEvent.build(...))`. For kind 78 the `d` tag is optional (it's a grouping key, not an addressing key), so we keep it nullable and assemble it via `DTag.assemble` — the typed `dTag()` extension is constrained to addressable events, which is correct. Update the lone caller (`AppSpecificState.saveNewAppSpecificData`). --- .../nip78AppSpecific/AppSpecificState.kt | 10 +++--- .../quartz/nip78AppData/AppDataEvent.kt | 33 ++++++------------- .../nip78AppData/AppSpecificDataEvent.kt | 31 ++++++----------- 3 files changed, 25 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt index 03e23e2456..f9b93ba58b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -54,11 +54,11 @@ class AppSpecificState( suspend fun saveNewAppSpecificData(): AppSpecificDataEvent { val toInternal = settings.syncedSettings.toInternal() - return AppSpecificDataEvent.create( - dTag = APP_SPECIFIC_DATA_D_TAG, - description = signer.nip44Encrypt(JsonMapper.toJson(toInternal), signer.pubKey), - otherTags = emptyArray(), - signer = signer, + return signer.sign( + AppSpecificDataEvent.build( + dTag = APP_SPECIFIC_DATA_D_TAG, + description = signer.nip44Encrypt(JsonMapper.toJson(toInternal), signer.pubKey), + ), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt index d5def1406d..af8a560efe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.quartz.nip78AppData import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -52,30 +54,15 @@ class AppDataEvent( const val KIND = 78 const val ALT = "Arbitrary app data" - suspend fun create( + fun build( content: String, dTag: String? = null, - otherTags: Array> = emptyArray(), - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): AppDataEvent { - val withD = - if (dTag == null) { - otherTags - } else if (otherTags.any { it.size > 1 && it[0] == "d" && it[1] == dTag }) { - otherTags - } else { - otherTags.filter { it.isEmpty() || it[0] != "d" }.toTypedArray() + arrayOf(arrayOf("d", dTag)) - } - - val newTags = - if (withD.none { it.isNotEmpty() && it[0] == "alt" }) { - withD + arrayOf(AltTag.assemble(ALT)) - } else { - withD - } - - return signer.sign(createdAt, KIND, newTags, content) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + dTag?.let { addUnique(DTag.assemble(it)) } + initializer() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt index 4d6b403d2d..85c4d9e290 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.quartz.nip78AppData import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils class AppSpecificDataEvent( @@ -52,28 +54,15 @@ class AppSpecificDataEvent( dTag: String, ): ATag = ATag(KIND, pubkey, dTag, null) - suspend fun create( + fun build( dTag: String, description: String, - otherTags: Array>, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): AppSpecificDataEvent { - val withD = - if (otherTags.any { it.size > 1 && it[0] == "d" && it[1] == dTag }) { - otherTags - } else { - otherTags.filter { it.size > 0 && it[0] != "d" }.toTypedArray() + arrayOf("d", dTag) - } - - val newTags = - if (withD.none { it.size > 0 && it[0] == "alt" }) { - withD + AltTag.assemble(ALT) - } else { - withD - } - - return signer.sign(createdAt, KIND, newTags, description) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + alt(ALT) + dTag(dTag) + initializer() } } } From 47af414e471ec0f548781e8400f3cae803c8a6ab Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 28 May 2026 21:38:48 +0000 Subject: [PATCH 09/51] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e773f40c5a..8f47054e2e 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -14,6 +14,12 @@ Ten post ma więcej niż %1$d hashtagów Ten post zawiera więcej niż %1$d hashtagów + + %1$d zestaw aktywów + %1$d zestawów aktywów + %1$d zestawów aktywów + %1$d zestawy aktywów + Wydarzenie jest wczytywane lub nie można go znaleźć na liście transmiterów 👀 Zdjęcie kanału @@ -129,6 +135,7 @@ Napisz wiadomość… Wyślij Zapisz + Wyślij Utwórz Zmień nazwę Anuluj @@ -567,6 +574,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Źródło: %1$s v%1$s Pobierz + O NIP-ie + Platformy + Tematy + Linki + Najnowsze wydanie + Komentarze + Bądź pierwszym, który skomentuje. + Pokaż starsze wersje + Ukryj starsze wersje Kalendarze Filmiki Czaty publiczne @@ -729,6 +745,30 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Brak serwerów Nest. Dodaj jeden poniżej lub wybierz polecany serwer. Filmy wideo Artykuły + Muzyka + Playlisty + Nowy utwór + Nowa playlista + Tytuł + Tytuł utworu + Artysta + Nazwa wykonawcy + Odnośnik audio + https://… .mp3 + Adres miniaturki (opcjonalnie) + Album (opcjonalnie) + Czas trwania w sekundach (opcjonalnie) + Teksty lub wkład twórczy (opcjonalne) + Usuń ten utwór + Opublikować NIP-09 informację o usunięciu tego utworu? + Dodaj do playlisty + Dodaj do playlisty + Nie masz jeszcze żadnych list odtwarzania. Skorzystaj z pola powyżej, aby utworzyć listę. + Zarządzaj wszystkimi playlistami + Nowa playlista + Nazwa playlisty + Utwórz + (niezatytułowana playlista) Prywatne Zakładki Publiczne zakładki Dodaj do prywatnych zakładek @@ -1368,6 +1408,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Skomentuj na stronie internetowej Skomentuj na zewnętrznym serwisie Czas czytania: %1$d min. + Współtworzona + Prywatna + Nieznany utwór + Ładowanie utworu… + Wgraj miniaturkę + Wybierz kwadratowe zdjęcie, które posłuży jako okładka tego utworu. + Wgraj plik audio + Wybierz plik w formacie MP3, WAV lub FLAC. Po kliknięciu „Zapisz” zostanie on przesłany na Twój serwer multimediów. + Plik audio gotowy do przesłania + Wgrywanie… + Wgraj miniaturę i plik audio, a następnie opublikuj. Proces ten będzie kontynuowany nawet po opuszczeniu ekranu. + Błąd wgrywania: %1$s Pobieranie lokalizacji Brak dostępu do lokalizacji Dodaje ostrzeżenie o wrażliwych treściach przed wyświetleniem treści. Jest to idealne dla dowolnych treści NSFW lub treści, które niektóre osoby mogą uznać za obraźliwe lub przeszkadzające @@ -2264,6 +2316,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ustawienia użytkownika Nagłówek audio Ścieżka audio + Utwór muzyczny + Playlista muzyczna Odznaki Definicje odznak Zestaw odznak „Przyjęty” From 6c3ceba2344714e9080327cb7f1aae2fe612f268 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 21:41:32 +0000 Subject: [PATCH 10/51] docs: add Verify, Don't Guess standing instruction to CLAUDE.md --- .claude/CLAUDE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index bb5ef6015a..fe58714491 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -20,6 +20,28 @@ nostrnests reference relay. The IETF code is kept as a reference + unit-test implementation for any future IETF target; see `nestsClient/plans/2026-04-26-moq-lite-gap.md`. +## Verify, Don't Guess (standing instruction) + +A plausible-sounding explanation is cheap; being right is not. Before +asserting what a problem is or how something behaves: + +1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm + guessing" or "haven't verified" — never dress an untested guess up as a + diagnosis. Use "I verified X by running Y" only when you actually did. +2. **Reproduce before diagnosing.** If a claim is checkable in under a + minute, check it before stating it. This repo gives you the means: + `./gradlew test`, the per-module tests, and `amy` (the CLI exists partly + to drive `quartz`/`commons` for interop checks). Write the failing case + first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix` + (reproduce-first) or `/investigate` (competing hypotheses + refutation). +3. **Predict, then run.** Before running a command, state the output you + expect. A mismatch is the cheapest signal that your model is wrong. +4. **Don't commit to one cause.** A single immediate explanation stops you + from looking. Hold 2–3 candidates and a discriminating test for each. + +If you find yourself writing paragraphs to defend a theory, that effort +almost always should have been one test. + ## Architecture ``` From aea885bab02e001263baa22a98552af25aebb35a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:12:53 +0000 Subject: [PATCH 11/51] docs: add Verify-Don't-Guess rule and trim CLAUDE.md - Add a standing instruction to test hypotheses before diagnosing (state guesses as guesses, reproduce-first, predict-then-run). - Remove content duplicated by the harness-injected skill list (skills tables, Commands section) and generic expect/actual examples. - Condense the Feature Workflow (removed the duplicated share/keep-native tables and the hardcoded grep block) and the skill-handshake example. - Fix stale facts: drop pinned tool versions (now point to libs.versions.toml) and correct the nestsClient tree comment to match the overview (production runs on moq-lite). --- .claude/CLAUDE.md | 244 +++++++++------------------------------------- 1 file changed, 48 insertions(+), 196 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fe58714491..760300654a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -62,7 +62,7 @@ amethyst/ │ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine │ ├── jvmAndroid/ # JCA-backed AEAD + UDP socket actuals │ └── commonTest/ # RFC vector + adversarial tests -├── nestsClient/ # Audio-room client (IETF MoQ-transport today; moq-lite phase pending) +├── nestsClient/ # Audio-room client (production runs on moq-lite; IETF MoQ kept as reference) │ └── src/ │ ├── commonMain/ # MoQ session, NestsListener, audio glue │ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack @@ -87,60 +87,33 @@ The global `docs/plans/` folder is frozen — don't add new plans there. ## Tech Stack +Exact versions live in `gradle/libs.versions.toml` (the source of truth — check +there rather than trusting a number copied here). + | Layer | Technology | |-------|------------| | **Core** | Quartz (Nostr KMP) | -| **UI** | Compose Multiplatform 1.10.3 | +| **UI** | Compose Multiplatform | | **Async** | kotlinx.coroutines + Flow | | **Network** | OkHttp (JVM) | | **Serialization** | Jackson | | **DI** | Manual / Koin | -| **Build** | Gradle 8.x, Kotlin 2.3.20 | +| **Build** | Gradle + Kotlin Multiplatform | ## Skills -Specialized skills provide domain expertise with bundled resources and patterns: +The full list of available skills (with descriptions and triggers) is injected +into every session, so it isn't duplicated here. Two kinds exist and are meant +to be used together: -| Skill | Expertise | When to Use | -|-------|-----------|-------------| -| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32, NIP-44, LargeCache | -| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs, common utilities | -| `kotlin-coroutines` | Advanced async patterns | supervisorScope, callbackFlow, relay pools, testing | -| `kotlin-multiplatform` | Platform abstraction | expect/actual, source sets, sharing decisions | -| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition, rich-text parsing | -| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3, Coil image loading | -| `desktop-expert` | Desktop platform | Window, MenuBar, keyboard shortcuts, DeckLayout | -| `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization | -| `account-state` | `Account` + `LocalCache` | Per-user StateFlows, event store, adding account-scoped settings | -| `relay-client` | Subscriptions & filter assembly | `ComposeSubscriptionManager`, assemblers, preloaders, EOSE | -| `feed-patterns` | Feeds & DAL | `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` family | -| `auth-signers` | `NostrSigner` implementations | Local, NIP-46 bunker, NIP-55 Android external signer | -| `quartz-integration` | Quartz as an external library | Gradle setup, `NostrClient`, `KeyPair`, for external projects | -| `amy-expert` | Amy CLI (`cli/` module) | Adding `amy ` commands, JSON output contract, extracting logic from `amethyst/` into `commons/` so CLI can call it | -| `find-missing-translations` | Utility | Extract untranslated Android strings | -| `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads | - -### Technique-layer skills (Compose / Kotlin best practices) - -These are general Compose/Kotlin decision-framework skills (vendored from -`chrisbanes/skills`). The skills above are **codebase-oriented** ("where is X in -Amethyst, what pattern do we use"); these are **technique-oriented** ("what is -the correct Compose/Kotlin design here"). They complement — not replace — the -codebase skills: e.g. `compose-expert` tells you where shared composables live, -`compose-slot-api-pattern` tells you how to shape their public API. - -| Skill | Expertise | Complements | -|-------|-----------|-------------| -| `compose-recomposition-performance` | Router: which recomposition axis is the problem | `compose-expert` | -| `compose-stability-diagnostics` | Compiler reports, strong skipping, `ImmutableList` at UI boundaries | `compose-expert`, `kotlin-expert` | -| `compose-state-deferred-reads` | Phase-aware state reads, block-form modifiers, provider lambdas | `compose-expert` | -| `compose-slot-api-pattern` | `@Composable` slot design for reusable components | `compose-expert` | -| `compose-modifier-and-layout-style` | `modifier` parameter conventions, chain construction, conditional hoisting | `compose-expert` | -| `compose-side-effects` | `LaunchedEffect`/`DisposableEffect`/`SideEffect`, keys, `rememberUpdatedState` | `compose-expert` | -| `compose-state-holder-ui-split` | State-holder vs plain-UI composable split | `compose-expert`, `feed-patterns` | -| `kotlin-flow-state-event-modeling` | StateFlow/SharedFlow/Channel choice, sentinels, `stateIn`, `update {}` | `kotlin-expert` | -| `kotlin-coroutines-structured-concurrency` | Stored-scope anti-pattern, `suspend` boundaries, `runBlocking`, cancellation | `kotlin-coroutines` | -| `kotlin-types-value-class` | `@JvmInline value class` vs `data class`, Compose stability | `kotlin-expert` | +- **Codebase-oriented** skills (`nostr-expert`, `compose-expert`, `feed-patterns`, + `account-state`, `amy-expert`, …) answer "where is X in Amethyst, what pattern + do we use here." +- **Technique-oriented** skills (vendored from `chrisbanes/skills`, e.g. + `compose-slot-api-pattern`, `kotlin-flow-state-event-modeling`) answer "what is + the correct Compose/Kotlin design." They complement, not replace, the codebase + skills: `compose-expert` tells you where shared composables live; + `compose-slot-api-pattern` tells you how to shape their public API. ## Workflow @@ -152,126 +125,42 @@ codebase skills: e.g. `compose-expert` tells you where shared composables live, 4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan 5. **Execute with skills** - Skills collaborate to implement the feature -**Example:** -``` -You: "Add video support to notes" -Me: "I'll use: - - /nostr-expert (NIP-71 video events) - - /compose-expert (video player UI) - - /android-expert (platform video APIs) - Proceed?" -You: "yes" -Me: [invokes skills to create plan] - "Plan from skills: - 1. nostr-expert: Use NIP-71 kind 34235 for video events... - 2. compose-expert: Create VideoPlayer composable in commons... - 3. android-expert: Use ExoPlayer for Android... - Proceed with implementation?" -You: "yes" -Me: [implements using skill guidance] -``` - -## Commands - -- `/desktop-run` - Build and run desktop app -- `/nip ` - Get NIP implementation guidance - ## Feature Workflow -**CRITICAL: Always check existing implementations first before creating new code!** +**CRITICAL: Check existing implementations first — most logic already exists.** +Before writing code, survey all modules (use Grep/Explore) for managers, caches, +state systems, filters, ViewModels, and composables that already do the job. Your +job is usually to **reuse** (`quartz` protocol/business logic), **extract** +(Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav — +not to duplicate existing managers, caches, or state. -When picking up a new task or feature, follow this process: +Capture the survey as a matrix in your plan: -### Step 0: Survey Existing Implementation (MANDATORY) +| File/Component | Status | Location | Action | +|----------------|--------|----------|--------| +| FilterBuilders | ✅ Reuse | quartz/relay/filters/ | Use as-is | +| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons | +| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern | -**Before writing ANY code, thoroughly audit ALL modules:** +**Legend:** ✅ Reuse (exists, use directly) · 📦 Extract (exists in Android, move +to `commons`) · 🆕 New (doesn't exist — platform-specific only) · ⚠️ Avoid +(duplicate; use existing pattern). -1. **Search for existing implementations across all modules:** - ```bash - # Search in quartz for protocol/business logic - grep -r "class.*Manager\|object.*Cache\|class.*Filter" quartz/src/commonMain/ +**Share vs keep platform-native:** - # Search in commons for UI components - grep -r "@Composable.*Card\|@Composable.*View\|@Composable.*Dialog" commons/src/ +- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and + `commons/commonMain/` (major UI components, **ViewModels** under + `viewmodels/`, icons). ViewModels are platform-agnostic state + logic + (StateFlow/SharedFlow), so they belong in `commons`. +- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android + `Activity`), navigation (sidebar vs bottom nav), platform interactions + (gestures, keyboard shortcuts), system integrations (notifications, file + pickers). - # Search in amethyst for Android patterns - grep -r "class.*ViewModel\|class.*Account\|class.*State" amethyst/src/main/java/ - - # Search for specific functionality - grep -r "fun isFollowing\|fun subscribe\|fun getMetadata" {quartz,commons,amethyst}/src/ - ``` - -2. **Understand existing architecture patterns:** - - Event stores and caching systems - - State management patterns (StateFlow, mutable states) - - ViewModel patterns and lifecycle handling - - Filter builders and relay subscription patterns - - UI component hierarchies - -3. **Key principle:** Most logic already exists! Your job is to: - - **Reuse** existing protocol/business logic from quartz - - **Extract** shareable UI components AND ViewModels from amethyst to commons - - Create **platform-specific** layouts/navigation for Desktop - - **NOT** duplicate existing managers, caches, or state systems - -4. **Document findings in implementation plan as a matrix:** - - | File/Component | Status | Location | Action | - |----------------|--------|----------|--------| - | FilterBuilders | ✅ Exists | quartz/relay/filters/ | Reuse as-is | - | NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons | - | HomeFeedViewModel | 📦 Extract | amethyst/ → commons/commonMain/viewmodels/ | Extract to commons | - | ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern | - - **Legend:** - - ✅ **Reuse** - Exists and can be used directly - - 📦 **Extract** - Exists in Android, needs extraction to commons - - 🆕 **New** - Doesn't exist, needs creation (platform-specific only) - - ⚠️ **Avoid** - Duplicate functionality, use existing pattern instead - -### Step 1: Analyze Android Implementation - -After surveying (Step 0), deeply examine the Android implementation: -1. Find the relevant feature/component in `amethyst/` module -2. Understand the current implementation patterns -3. Identify dependencies and integrations -4. Map out what code can be shared vs platform-specific - -### Step 2: Create Implementation Plan - -Before coding, create a plan that categorizes work into three buckets: - -| Category | Description | Location | -|----------|-------------|----------| -| **Android-Specific** | Platform-native layouts, navigation patterns | `amethyst/`, `androidMain/` | -| **Reusable (Shared)** | Business logic, UI components, **ViewModels**, state management | `quartz/commonMain/`, `commons/commonMain/` | -| **Desktop-Specific** | Desktop-native layouts, navigation patterns, platform APIs | `desktopApp/`, `jvmMain/` | - -### Step 3: Code Sharing Strategy - -**Share:** -- Business logic and data models → `quartz/commonMain/` -- Major UI components (cards, lists, dialogs) → `commons/commonMain/` -- **ViewModels** (state, business logic) → `commons/commonMain/viewmodels/` -- Icons and visual assets → `commons/commonMain/` - -**Keep Platform-Native:** -- **Screen composables** (layout, scaffolding) - Desktop uses `Window`, Android uses `Activity` -- Navigation patterns (sidebar vs bottom nav) -- Platform-specific interactions (gestures, keyboard shortcuts) -- System integrations (notifications, file pickers) - -**Rationale:** ViewModels contain platform-agnostic state management (StateFlow/SharedFlow) and business logic. Screens consume ViewModels but render differently (Desktop sidebar + content area vs Android bottom nav). - -### Step 4: Extract Shared Components - -When extracting UI components: -1. Identify reusable composables in Android code -2. Move to `commons/commonMain/` (consult `/compose-expert` for patterns) -3. Create expect/actual declarations for platform-specific behavior (consult `/kotlin-multiplatform`) -4. Update both Android and Desktop to use shared component - -**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP. +When extracting a composable: move it to `commons/commonMain/` (see +`/compose-expert`), add expect/actual for any platform behavior (see +`/kotlin-multiplatform`), then point both Android and Desktop at the shared +version. `quartz/` is protocol-only — no composables. ## Build Commands @@ -294,39 +183,9 @@ When extracting UI components: ## Quartz KMP Structure -The Quartz library uses expect/actual for platform-specific implementations: - -```kotlin -// commonMain - shared protocol logic -expect class CryptoProvider { - fun sign(message: ByteArray, privateKey: ByteArray): ByteArray - fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean -} - -// androidMain - uses secp256k1-kmp-jni-android -actual class CryptoProvider { /* Android implementation */ } - -// jvmMain - uses secp256k1-kmp-jni-jvm -actual class CryptoProvider { /* JVM implementation */ } -``` - -## Key Patterns - -### Platform Abstraction -```kotlin -// commonMain -expect fun openExternalUrl(url: String) - -// androidMain -actual fun openExternalUrl(url: String) { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) -} - -// jvmMain (Desktop) -actual fun openExternalUrl(url: String) { - Desktop.getDesktop().browse(URI(url)) -} -``` +Quartz uses expect/actual for platform-specific implementations (e.g. crypto +backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on +JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns. ## Icons @@ -374,12 +233,5 @@ Do this before considering the task complete. ## Git Workflow -- Branch: `feat/desktop-` or `fix/desktop-` - Commits: Conventional commits (`feat:`, `fix:`, etc.) - Never use `--no-verify` - -## Resources - -- [Nostr NIPs](https://github.com/nostr-protocol/nips) -- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) -- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) From ab0719fa680cda70ca3cf7ab78f6f1df8edb84e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:14:49 +0000 Subject: [PATCH 12/51] fix(podcasts): wire podcast kinds into every consumer; close audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 10 audit findings from the post-build review: CRITICAL — non-functional without this: - LocalCache.justConsume had no branches for kind 54 / 10054 / 10064 / 10154, so every podcast event fell through to "Event Not Supported" and was silently dropped. Added the four explicit branches (regular event for PodcastEpisode; replaceable for the other three). HIGH — silent invisibility / broken tap-through: - Home, profile (newthreads + mutual), hashtag, geohash, follow-pack, and notification feed filters didn't recognize PodcastEpisodeEvent / PodcastMetadataEvent. Episodes were invisible everywhere outside the dedicated tab; reactions/zaps on episodes were dropped from the notifications feed. - ThreadFeedView's renderer dispatch had no podcast branch, so tapping a feed card opened a plain text-note view. Added explicit cases that call the new RenderPodcastEpisode / RenderPodcastMetadata composables. - The hashtag / geohash / relay / search REQ kind lists didn't include podcast kinds, so discovery surfaces returned nothing for them. - RelayInformationScreen kind→label map gained podcast entries + 4 new string resources (Podcast Episode, Podcast Show, Authored Podcasts, Favorite Podcasts). - HomeNewThreadFeedFilter.ADDRESSABLE_KINDS gained PodcastMetadataEvent so shows surface alongside music/wiki/long-form on the home feed. HIGH — privacy leak in Quartz: - FavoritePodcastsListEvent.add(isPrivate=true) was passing earlierVersion.tags through untouched, so toggling a previously-public favorite to private left the public p-tag intact. Made both branches symmetric: each removes the entry from the other half before adding to its own. Two regression tests cover the round-trip. MEDIUM — data hygiene: - AuthorTag.parse used to accept ANY non-empty slot-2 string as a role (rendering a stray relay-hint URL as "Role: wss://relay…"). Now validates against the spec-defined {host, cohost, editor} allowlist; unknown values resolve to role=null, preserving the pubkey association. PERF: - PodcastEpisode renderer was allocating a fresh 96-element WaveformData and rebuilding the cover Modifier chain per visible card. Hoisted both to top-level constants (FLAT_WAVEFORM, COVER_IMAGE_MODIFIER, PLAYER_BORDER_MODIFIER) so the whole feed shares one instance. CODE QUALITY: - Extracted PodcastCoverCard as a shared composable used by both renderers (was duplicated byte-identical across PodcastEpisode + PodcastMetadata). - Extracted PodcastFeedLoaded so the Episodes screen and Shows screen share one feed body (was duplicated byte-identical). - Dropped the misleading `group = listOf(singleAssembler)` wrapper in the two FilterAssembler files. - Replaced `mapNotNull { … }.flatten()` with `flatMap { … }` in the Communities sub-assembly (the lambda never returns null). DOCUMENTED: - PODCAST_KINDS "Following" resolution still goes through kind:3 follows, but per NIP-F4 podcasts use their own keypairs tracked via kind:10054. Added an inline comment naming the deferred work — proper fix needs Account-level 10054 integration which is a separate scope. --- .../amethyst/model/LocalCache.kt | 20 ++++ .../subassemblies/SearchPostsByText.kt | 4 + .../ui/note/types/PodcastCoverCard.kt | 72 ++++++++++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 110 ++++-------------- .../amethyst/ui/note/types/PodcastMetadata.kt | 52 +-------- .../dal/FollowPackFeedNewThreadFeedFilter.kt | 5 + .../loggedIn/geohash/dal/GeoHashFeedFilter.kt | 6 +- .../datasource/FilterPostsByGeohash.kt | 4 + .../loggedIn/hashtag/dal/HashtagFeedFilter.kt | 6 +- .../datasource/FilterPostsByHashtags.kt | 4 + .../home/dal/HomeNewThreadFeedFilter.kt | 5 + .../dal/NotificationFeedFilter.kt | 4 + .../podcasts/PodcastEpisodesFeedLoaded.kt | 14 ++- .../loggedIn/podcasts/PodcastsFeedLoaded.kt | 44 +------ .../PodcastEpisodesFilterAssembler.kt | 9 +- .../datasource/PodcastsFilterAssembler.kt | 9 +- .../FilterPodcastEventsByAuthors.kt | 7 ++ .../FilterPodcastEventsByCommunities.kt | 36 +++--- .../mutual/dal/UserProfileMutualFeedFilter.kt | 4 + .../dal/UserProfileNewThreadFeedFilter.kt | 4 + .../relay/datasource/FilterPostsByRelay.kt | 4 + .../loggedIn/relays/RelayInformationScreen.kt | 8 ++ .../loggedIn/threadview/ThreadFeedView.kt | 8 ++ amethyst/src/main/res/values/strings.xml | 4 + .../favorites/FavoritePodcastsListEvent.kt | 28 +++-- .../nipF4Podcasts/metadata/tags/AuthorTag.kt | 7 +- .../FavoritePodcastsListEventTest.kt | 54 +++++++++ 27 files changed, 311 insertions(+), 221 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ae0555fc0f..a376c7a6e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -260,6 +260,10 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -3397,6 +3401,22 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is PodcastEpisodeEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is PodcastMetadataEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is AuthoredPodcastsEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is FavoritePodcastsListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is LnZapEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 0a02216589..1a6e0be1d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -56,6 +56,8 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val SearchPostsByTextKinds1 = listOf( @@ -69,6 +71,8 @@ val SearchPostsByTextKinds1 = AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, PinListEvent.KIND, ZapPollEvent.KIND, ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt new file mode 100644 index 0000000000..9e04361395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt @@ -0,0 +1,72 @@ +/* + * 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.note.types + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +// Top-rounded square cover used by both PodcastEpisode and PodcastMetadata renderers. +// Shape + modifier hoisted to constants so they aren't rebuilt on every recomposition. +private val COVER_IMAGE_SHAPE = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) +private val COVER_IMAGE_MODIFIER = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(COVER_IMAGE_SHAPE) + +@Composable +internal fun PodcastCoverCard( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + Box(COVER_IMAGE_MODIFIER) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = COVER_IMAGE_MODIFIER, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, COVER_IMAGE_MODIFIER) }, + onError = { DefaultImageHeader(note, accountViewModel, COVER_IMAGE_MODIFIER) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, COVER_IMAGE_MODIFIER) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index def17de157..65c230a3bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -21,12 +21,9 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -40,29 +37,32 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem -import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent -private val COVER_ASPECT_RATIO = 1f +// NIP-F4 doesn't carry a waveform tag, so every episode renders with the same flat baseline. +// Hoist to a top-level constant so we share one List+WaveformData across the whole +// feed instead of allocating a fresh 96-element list per visible card. +private const val WAVEFORM_SAMPLES = 96 +private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) + +// Bottom-rounded border on the audio player so it visually butts up against the cover's +// top-rounded corners as one card. Constant — keep out of recomposition. +private val PLAYER_BORDER_MODIFIER = + Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) @Composable fun RenderPodcastEpisode( @@ -75,51 +75,20 @@ fun RenderPodcastEpisode( ) { val noteEvent = note.event as? PodcastEpisodeEvent ?: return - PodcastEpisodeBody( - noteEvent = noteEvent, - note = note, - makeItShort = makeItShort, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -private fun PodcastEpisodeBody( - noteEvent: PodcastEpisodeEvent, - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: INav, -) { val title = remember(noteEvent) { noteEvent.title() } val image = remember(noteEvent) { noteEvent.image() } val description = remember(noteEvent) { noteEvent.description() } - // Pick the first audio URL. If a publisher provides multiple containers, this is the - // ordering they emitted — clients with codec preferences can extend this later. + // Pick the first audio URL. Publishers may emit multiple containers in their preferred + // order; clients with codec preferences can extend this later. val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } - // Suppress the markdown content block if it's blank (the title + description already - // describe a short episode); otherwise let RichText render it below. - val markdown = - remember(noteEvent) { - noteEvent.content.ifBlank { null } - } + // Suppress the markdown block if blank — title + description already describe a short + // episode. Otherwise hand off to RichText below. + val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } } Column(MaterialTheme.colorScheme.replyModifier) { - PodcastEpisodeCover(image, note, accountViewModel) + PodcastCoverCard(image, note, accountViewModel) firstAudio?.let { audio -> - // No waveform tag in NIP-F4, synthesize a flat baseline. ExoPlayer drives - // actual playback progress; the bars are purely decorative. - val waveform = remember(note.idHex) { flatWaveform() } - val callbackUri = remember(note) { note.toNostrUri() } - val playerBorder = - remember { - Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) - } + val callbackUri = remember(noteEvent) { note.toNostrUri() } Row( Modifier.fillMaxWidth().height(80.dp), @@ -135,7 +104,7 @@ private fun PodcastEpisodeBody( aspectRatio = null, proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), keepPlaying = false, - waveformData = waveform, + waveformData = FLAT_WAVEFORM, ) { mediaItem -> GetVideoController( mediaItem = mediaItem, @@ -145,8 +114,8 @@ private fun PodcastEpisodeBody( RenderVoicePlayer( mediaItem = mediaItem, controllerState = controller, - waveform = waveform, - borderModifier = playerBorder, + waveform = FLAT_WAVEFORM, + borderModifier = PLAYER_BORDER_MODIFIER, accountViewModel = accountViewModel, ) } @@ -185,7 +154,7 @@ private fun PodcastEpisodeBody( markdown?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } - val callbackUri = remember(note) { note.toNostrUri() } + val callbackUri = remember(noteEvent) { note.toNostrUri() } TranslatableRichTextViewer( content = it, @@ -203,40 +172,3 @@ private fun PodcastEpisodeBody( } } } - -@Composable -private fun PodcastEpisodeCover( - image: String?, - note: Note, - accountViewModel: AccountViewModel, -) { - val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) - val imageModifier = - Modifier - .fillMaxWidth() - .aspectRatio(COVER_ASPECT_RATIO) - .clip(imageShape) - - Box(imageModifier) { - if (image != null) { - MyAsyncImage( - imageUrl = image, - contentDescription = stringRes(R.string.preview_card_image_for, image), - contentScale = ContentScale.Crop, - mainImageModifier = Modifier.fillMaxSize(), - loadedImageModifier = imageModifier, - accountViewModel = accountViewModel, - onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, - onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, - ) - } else { - DefaultImageHeader(note, accountViewModel, imageModifier) - } - } -} - -private const val WAVEFORM_SAMPLES = 96 - -private fun flatWaveform(): WaveformData = - // Even baseline — purely decorative since NIP-F4 doesn't carry waveform data. - WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index beb8bdbcbc..03e880e5dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -21,51 +21,38 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -private val COVER_ASPECT_RATIO = 1.0f - @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderPodcastMetadata( note: Note, makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, + @Suppress("UNUSED_PARAMETER") canPreview: Boolean, + @Suppress("UNUSED_PARAMETER") backgroundColor: MutableState, accountViewModel: AccountViewModel, - nav: INav, + @Suppress("UNUSED_PARAMETER") nav: INav, ) { val noteEvent = note.event as? PodcastMetadataEvent ?: return @@ -75,7 +62,7 @@ fun RenderPodcastMetadata( val websites = remember(noteEvent) { noteEvent.websites() } Column(MaterialTheme.colorScheme.replyModifier) { - PodcastCover(image, note, accountViewModel) + PodcastCoverCard(image, note, accountViewModel) Column( modifier = @@ -124,34 +111,3 @@ fun RenderPodcastMetadata( } } } - -@Composable -private fun PodcastCover( - image: String?, - note: Note, - accountViewModel: AccountViewModel, -) { - val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) - val imageModifier = - Modifier - .fillMaxWidth() - .aspectRatio(COVER_ASPECT_RATIO) - .clip(imageShape) - - Box(imageModifier) { - if (image != null) { - MyAsyncImage( - imageUrl = image, - contentDescription = stringRes(R.string.preview_card_image_for, image), - contentScale = ContentScale.Crop, - mainImageModifier = Modifier.fillMaxSize(), - loadedImageModifier = imageModifier, - accountViewModel = accountViewModel, - onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, - onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, - ) - } else { - DefaultImageHeader(note, accountViewModel, imageModifier) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index 49866f79ba..e30adf0f71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class FollowPackFeedNewThreadFeedFilter( val followPackNote: AddressableNote, @@ -58,6 +60,7 @@ class FollowPackFeedNewThreadFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, @@ -141,6 +144,8 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is AudioTrackEvent || noteEvent is MusicTrackEvent || noteEvent is MusicPlaylistEvent || + noteEvent is PodcastEpisodeEvent || + noteEvent is PodcastMetadataEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index f2b7319928..52f726d396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class GeoHashFeedFilter( val tag: String, @@ -82,7 +84,9 @@ class GeoHashFeedFilter( event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || - event is MusicPlaylistEvent + event is MusicPlaylistEvent || + event is PodcastEpisodeEvent || + event is PodcastMetadataEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 257dba9cc7..0fa9ec6fc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -38,6 +38,8 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByGeohashKinds = listOf( @@ -52,6 +54,8 @@ val PostsByGeohashKinds = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, WikiNoteEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index daf3a81470..a3f47452e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class HashtagFeedFilter( val tag: String, @@ -86,7 +88,9 @@ class HashtagFeedFilter( event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || - event is MusicPlaylistEvent + event is MusicPlaylistEvent || + event is PodcastEpisodeEvent || + event is PodcastMetadataEvent ) && event.isTaggedHash(hashTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index ca2b17014b..4941239783 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -43,6 +43,8 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByHashtagsKinds = listOf( @@ -64,6 +66,8 @@ val PostsByHashtagKinds2 = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 1e7f0dba26..0499722ff1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -51,6 +51,8 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class HomeNewThreadFeedFilter( val account: Account, @@ -61,6 +63,7 @@ class HomeNewThreadFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, ClassifiedsEvent.KIND, @@ -131,6 +134,8 @@ class HomeNewThreadFeedFilter( noteEvent is AudioTrackEvent || noteEvent is MusicTrackEvent || noteEvent is MusicPlaylistEvent || + noteEvent is PodcastEpisodeEvent || + noteEvent is PodcastMetadataEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 18800f93dc..b9a379d746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -78,6 +78,8 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -98,6 +100,8 @@ class NotificationFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarRSVPEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt index 4abe4c7a9c..ce36765f75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt @@ -39,8 +39,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +// One shared body for both Episodes (kind 54) and Shows (kind 10154) — NoteCompose's +// renderer dispatch picks the right card per item, the list scaffolding is identical. @Composable -fun PodcastEpisodesFeedLoaded( +internal fun PodcastFeedLoaded( loaded: FeedState.Loaded, listState: LazyListState, accountViewModel: AccountViewModel, @@ -57,8 +59,6 @@ fun PodcastEpisodesFeedLoaded( key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> - // NoteCompose wraps the episode card with the standard reactions row + author chrome - // so zap/reply/etc. work the same as on the home feed. NoteCompose( baseNote = item, modifier = Modifier, @@ -74,3 +74,11 @@ fun PodcastEpisodesFeedLoaded( } } } + +@Composable +fun PodcastEpisodesFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt index 55389e62f6..2827387975 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt @@ -20,55 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding +// Episodes (kind 54) and Shows (kind 10154) feeds render identically — each note flows +// through NoteCompose's renderer dispatch. Keeping one shared composable means a divider +// or padding tweak ships to both screens at once. @Composable fun PodcastsFeedLoaded( loaded: FeedState.Loaded, listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, -) { - val items by loaded.feed.collectAsStateWithLifecycle() - - LazyColumn( - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - itemsIndexed( - items.list, - key = { _, item -> item.idHex }, - contentType = { _, item -> item.event?.kind ?: -1 }, - ) { _, item -> - NoteCompose( - baseNote = item, - modifier = Modifier, - isBoostedNote = false, - quotesLeft = 1, - accountViewModel = accountViewModel, - nav = nav, - ) - - HorizontalDivider(thickness = DividerThickness) - - Spacer(modifier = Modifier.height(8.dp)) - } - } -} +) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt index 19248cc6b4..efb9bdb167 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt @@ -37,14 +37,11 @@ class PodcastEpisodesQueryState( class PodcastEpisodesFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { - val group = - listOf( - PodcastEpisodesSubAssembler(client, ::allKeys), - ) + private val episodes = PodcastEpisodesSubAssembler(client, ::allKeys) override fun invalidateKeys() = invalidateFilters() - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + override fun invalidateFilters() = episodes.invalidateFilters() - override fun destroy() = group.forEach { it.destroy() } + override fun destroy() = episodes.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt index 84b70f1989..85d6e8c995 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt @@ -37,14 +37,11 @@ class PodcastsQueryState( class PodcastsFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { - val group = - listOf( - PodcastsSubAssembler(client, ::allKeys), - ) + private val podcasts = PodcastsSubAssembler(client, ::allKeys) override fun invalidateKeys() = invalidateFilters() - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + override fun invalidateFilters() = podcasts.invalidateFilters() - override fun destroy() = group.forEach { it.destroy() } + override fun destroy() = podcasts.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 0feba30fba..4a56858280 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -35,6 +35,13 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND) internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) +// NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is +// its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list — +// they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent). +// With the current resolution the "Following" Podcasts tab returns content only if the user +// also kind:3-follows a podcast key. A future change should plumb a TopFilter.PodcastFavorites +// selector through the topNav state and resolve authors via the user's own 10054 list. + fun filterPodcastEventsByAuthors( relay: NormalizedRelayUrl, kinds: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index 697f3abf19..beb0d05142 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -73,15 +73,14 @@ fun filterPodcastEventsByAllCommunities( ): List { if (communitySet.set.isEmpty()) return emptyList() - return communitySet.set - .mapNotNull { - filterPodcastEventsFromAllCommunities( - relay = it.key, - kinds = kinds, - communities = it.value.communities, - since = since?.get(it.key)?.time ?: defaultSince, - ) - }.flatten() + return communitySet.set.flatMap { + filterPodcastEventsFromAllCommunities( + relay = it.key, + kinds = kinds, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } } fun filterPodcastEventsFromCommunity( @@ -131,14 +130,13 @@ fun filterPodcastEventsByCommunity( ): List { if (communitySet.set.isEmpty()) return emptyList() - return communitySet.set - .mapNotNull { - filterPodcastEventsFromCommunity( - relay = it.key, - kinds = kinds, - community = it.value.community, - authors = it.value.authors, - since = since?.get(it.key)?.time ?: defaultSince, - ) - }.flatten() + return communitySet.set.flatMap { + filterPodcastEventsFromCommunity( + relay = it.key, + kinds = kinds, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index 683a74efb4..5d20c01627 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class UserProfileMutualFeedFilter( val user: User, @@ -86,6 +88,8 @@ class UserProfileMutualFeedFilter( it.event is AudioTrackEvent || it.event is MusicTrackEvent || it.event is MusicPlaylistEvent || + it.event is PodcastEpisodeEvent || + it.event is PodcastMetadataEvent || it.event is AudioHeaderEvent || it.event is PublicMessageEvent || it.event is TorrentEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index d1e5ce0d4d..6b49c59c06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -50,6 +50,8 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class UserProfileNewThreadFeedFilter( val user: User, @@ -93,6 +95,8 @@ class UserProfileNewThreadFeedFilter( it.event is AudioTrackEvent || it.event is MusicTrackEvent || it.event is MusicPlaylistEvent || + it.event is PodcastEpisodeEvent || + it.event is PodcastMetadataEvent || it.event is AudioHeaderEvent || it.event is VoiceEvent || it.event is TorrentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index 69fb17f108..9174f45a76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -40,6 +40,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByRelayKinds = listOf( @@ -61,6 +63,8 @@ val PostsByRelayKinds2 = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 310df52ed6..4e2c151cd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -261,6 +261,10 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -540,6 +544,10 @@ fun kindDisplayName(kind: Int): Int = AudioTrackEvent.KIND -> R.string.kind_audio_track MusicTrackEvent.KIND -> R.string.kind_music_track MusicPlaylistEvent.KIND -> R.string.kind_music_playlist + PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode + PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata + AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts + FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts AttestationEvent.KIND -> R.string.attestation AttestationRequestEvent.KIND -> R.string.attestation_request AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index a5c7ecc0b5..244bf222ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -173,6 +173,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage @@ -308,6 +310,8 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -646,6 +650,10 @@ private fun FullBleedNoteCompose( RenderMusicTrack(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is MusicPlaylistEvent) { RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PodcastEpisodeEvent) { + RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PodcastMetadataEvent) { + RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c06d3b6217..ef898c3cc7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2598,6 +2598,10 @@ Audio Track Music Track Music Playlist + Podcast Episode + Podcast Show + Authored Podcasts + Favorite Podcasts Badge Awards Badge Definitions Accepted Badge Set diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt index 409e1ba484..e492348c9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt @@ -86,19 +86,33 @@ class FavoritePodcastsListEvent( val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + // MUST strip from public too — a podcast that was previously public and is now + // being made private must not stay visible in the unencrypted tag list. resign( - tags = earlierVersion.tags, + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()), privateTags = privateTags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), signer = signer, createdAt = createdAt, ) } else { - resign( - content = earlierVersion.content, - tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), - signer = signer, - createdAt = createdAt, - ) + // Symmetric: strip from private tags too when moving a previously-private + // podcast to the public list, otherwise it sits in both halves. + val privateTags = earlierVersion.privateTags(signer) + if (privateTags != null) { + resign( + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + privateTags = privateTags.remove(podcast.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } } suspend fun remove( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt index 23deb816ce..b8a05e88e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt @@ -49,13 +49,18 @@ class AuthorTag( const val ROLE_COHOST = "cohost" const val ROLE_EDITOR = "editor" + private val KNOWN_ROLES = setOf(ROLE_HOST, ROLE_COHOST, ROLE_EDITOR) + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 fun parse(tag: Array): AuthorTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + // NIP-F4 overloads slot 2 as role — but only host/cohost/editor are spec-defined. + // Anything else (notably a stale relay-hint URL copied from a kind:3 p-tag) gets + // dropped so it doesn't render as 'Role: wss://relay…' under the author chip. + val role = tag.getOrNull(2)?.takeIf { it in KNOWN_ROLES } return AuthorTag(tag[1], role) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt index cf177d88ae..98a3570f3e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt @@ -121,6 +121,60 @@ class FavoritePodcastsListEventTest { ) } + @Test + fun `moving public favorite to private strips the public p-tag`() = + runTest { + val publicFirst = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val nowPrivate = + FavoritePodcastsListEvent.add( + earlierVersion = publicFirst, + podcast = UserTag(podcast1), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertFalse( + nowPrivate.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Moving a public favorite to private must strip it from the unencrypted tag list", + ) + val priv = assertNotNull(nowPrivate.privateFavorites(signer)) + assertEquals(listOf(podcast1), priv.map { it.pubKey }) + } + + @Test + fun `moving private favorite to public strips the encrypted entry`() = + runTest { + val privateFirst = + FavoritePodcastsListEvent.create( + privateFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val nowPublic = + FavoritePodcastsListEvent.add( + earlierVersion = privateFirst, + podcast = UserTag(podcast1), + isPrivate = false, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertTrue( + nowPublic.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Promoted entry must be present as a public p-tag", + ) + val priv = assertNotNull(nowPublic.privateFavorites(signer)) + assertTrue(priv.isEmpty(), "Promoted entry must no longer be in the encrypted list") + } + @Test fun `remove drops the matching pubkey from public tags`() = runTest { From c679a870ab40d82f8fe8d9674b1088531932241c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:22:59 +0000 Subject: [PATCH 13/51] Respect Android system date/time format preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcoded date/time patterns ignored the user's Locale (date order: dd/mm/yyyy vs mm/dd/yyyy vs yyyy-mm-dd) and the system 12/24-hour override. Replace them with locale-aware formatters that resolve order from the active Locale via DateFormat.getBestDateTimePattern() and pick the time-of-day pattern via DateFormat.is24HourFormat(context). - TimeAgoFormatter (amethyst + commons): build SimpleDateFormat from Unicode LDML skeletons (yMMMd / MMMd / yMMM) so "May 28, 2026" in en-US becomes "28 May 2026" in en-GB, "28.05.2026" in de-DE, etc. - CalendarTimeFormat: same skeleton approach for date pieces; time uses DateFormat.getTimeFormat(context) so a 24-hour Android user sees 14:32 even on a 12-hour locale. - New LocalizedDateTimeFormat helper with formatMonthDayTime, formatMediumDate, formatMediumDateTime — used by wallet, vanish, attestation, namecoin, eventsync screens to replace inline SimpleDateFormat("MMM d, HH:mm") / ("MMM dd, yyyy hh:mm a") etc. - Material3 TimePicker callers (calendar/nest/poll/zap-poll/expiration date pickers + vanish request) now pass is24Hour from the system setting instead of hardcoding false. - Desktop article/reads/highlights screens use java.text.DateFormat.getDateInstance(MEDIUM, locale). - Drop dead formattedDateTime() in RelayCompose (was unused). Intentionally left alone: notification feed bucket keys ("yyyy-MM-dd" used as Map keys), TakePicture file naming (Locale.US), iCalendar RFC 5545 stamps, NIP-52 ISO date storage, internal logging, and ThreadLevelCalculator sort keys — none are user-facing. --- .../ui/note/LocalizedDateTimeFormat.kt | 74 ++++++++++ .../amethyst/ui/note/RelayCompose.kt | 9 -- .../amethyst/ui/note/TimeAgoFormatter.kt | 133 +++++------------- .../expiration/ExpirationDatePicker.kt | 6 +- .../note/creators/polls/PollDeadlinePicker.kt | 6 +- .../zappolls/ZapPollDeadlinePicker.kt | 6 +- .../amethyst/ui/note/types/Attestation.kt | 15 +- .../loggedIn/calendars/CalendarDayView.kt | 4 +- .../calendars/CalendarEventListCard.kt | 2 +- .../loggedIn/calendars/CalendarTimeFormat.kt | 60 +++++--- .../create/CalendarDateTimePickerButton.kt | 8 +- .../detail/CalendarEventDetailScreen.kt | 4 +- .../loggedIn/nests/create/CreateNestSheet.kt | 5 +- .../relays/eventsync/EventSyncScreen.kt | 15 +- .../relays/vanish/RequestToVanishScreen.kt | 16 +-- .../relays/vanish/VanishEventsScreen.kt | 13 +- .../settings/NamecoinSettingsSection.kt | 10 +- .../wallet/OnchainTransactionsScreen.kt | 14 +- .../wallet/WalletTransactionsScreen.kt | 13 +- .../amethyst/commons/util/TimeAgoFormatter.kt | 18 ++- .../desktop/ui/ArticleReaderScreen.kt | 14 +- .../amethyst/desktop/ui/ReadsScreen.kt | 15 +- .../ui/highlights/MyHighlightsScreen.kt | 16 +-- 23 files changed, 240 insertions(+), 236 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt new file mode 100644 index 0000000000..70ecc2dc71 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt @@ -0,0 +1,74 @@ +/* + * 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.note + +import android.content.Context +import android.text.format.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Date/time formatters that respect the user's Android system settings: + * - Date order (dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd…) is derived from the + * active [Locale] via [DateFormat.getBestDateTimePattern]. + * - 12-hour vs 24-hour clock follows the system setting via + * [DateFormat.is24HourFormat] (which is the user's manual override on + * top of the locale default). + * + * All formatters here use Unicode LDML skeletons. `j` and `jm` are intentionally + * avoided — `j` only picks 12/24 hour from the *locale*, not the user override. + * We pick the time skeleton explicitly based on [DateFormat.is24HourFormat]. + */ + +private fun timeSkeleton(context: Context): String = if (DateFormat.is24HourFormat(context)) "Hm" else "hma" + +private fun bestPattern( + context: Context, + skeletonBase: String, + includeTime: Boolean, +): SimpleDateFormat { + val locale = Locale.getDefault() + val skeleton = if (includeTime) skeletonBase + timeSkeleton(context) else skeletonBase + return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale) +} + +/** Locale-aware month + day + short time (e.g. "May 28, 14:32" / "28 May, 2:32 PM"). */ +fun formatMonthDayTime( + epochSeconds: Long, + context: Context, +): String = bestPattern(context, "MMMd", includeTime = true).format(Date(epochSeconds * 1000L)) + +/** Locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026"). */ +fun formatMediumDate( + epochSeconds: Long, + context: Context, +): String = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L)) + +/** Locale-aware medium date + short time, respecting the system 12/24-hour setting. */ +fun formatMediumDateTime( + epochSeconds: Long, + context: Context, +): String { + val date = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L)) + val time = DateFormat.getTimeFormat(context).format(Date(epochSeconds * 1000L)) + return "$date $time" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 26e539624a..18ef92c89d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -50,9 +50,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun RelayCompose( @@ -145,9 +142,3 @@ fun RemoveRelayButton(onClick: () -> Unit) { Text(text = stringRes(R.string.remove)) } } - -fun formattedDateTime(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 457d144f44..1491014263 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -32,25 +32,33 @@ import java.util.Date import java.util.Locale import kotlin.math.round -private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" -private const val MONTH_DATE_FORMAT = "MMM dd" +// Skeletons follow Unicode LDML — DateFormat.getBestDateTimePattern picks the +// correct locale-specific ordering (e.g. "MMM d, y" in en-US vs "d MMM y" in en-GB). +private const val YEAR_SKELETON = "yMMMd" +private const val MONTH_SKELETON = "MMMd" +private const val YEAR_NO_DAY_SKELETON = "yMMM" -private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy" -private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd" +private var locale: Locale = Locale.getDefault() +private var yearFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_SKELETON), locale) +private var monthFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, MONTH_SKELETON), locale) +private var yearNoDayFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_NO_DAY_SKELETON), locale) -var locale: Locale = Locale.getDefault() -var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) -var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - -var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) -var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) +private fun updateFormattersIfNeeded() { + val current = Locale.getDefault() + if (locale != current) { + locale = current + yearFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_SKELETON), locale) + monthFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, MONTH_SKELETON), locale) + yearNoDayFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_NO_DAY_SKELETON), locale) + } +} /** * Formats a Unix timestamp (seconds) as an absolute date/time string, picking the * granularity from how far away the timestamp is: - * - same day → time only (e.g. "14:32" / "2:32 PM", locale-aware) - * - same year → "MMM dd, HH:mm" - * - older → "MMM dd, yyyy" + * - same day → time only (locale + system 12/24-hr aware via [DateFormat.getTimeFormat]) + * - same year → "Jan 5, 14:32" / "5 Jan 14:32" / "Jan 5, 2:32 PM" (locale + system aware) + * - older → "Jan 5, 2024" / "5 Jan 2024" (locale aware) * * Used by [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] when the user * taps the relative timestamp to reveal the absolute one. @@ -63,11 +71,7 @@ fun timeAbsolute( if (time == null) return " " if (time == 0L) return prefix + stringRes(context, R.string.never) - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } + updateFormattersIfNeeded() val timeMs = time * 1000 val now = Calendar.getInstance() @@ -106,25 +110,12 @@ fun timeAgo( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() prefix + yearFormatter.format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() prefix + monthFormatter.format(time * 1000) } @@ -157,25 +148,12 @@ fun timeAgoNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() yearFormatter.format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() monthFormatter.format(time * 1000) } @@ -208,26 +186,13 @@ fun timeAgoNoDotNoDay( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) - monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() yearNoDayFormatter.format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) - monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) - } - - monthNoDayFormatter.format(time * 1000) + updateFormattersIfNeeded() + monthFormatter.format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -259,25 +224,12 @@ fun timeAheadNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() yearFormatter.format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() monthFormatter.format(time * 1000) } @@ -310,23 +262,10 @@ fun dateFormatter( val timeDifference = TimeUtils.now() - time return if (timeDifference > TimeUtils.ONE_YEAR) { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() yearFormatter.format(time * 1000) } else if (timeDifference > TimeUtils.ONE_DAY) { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - + updateFormattersIfNeeded() monthFormatter.format(time * 1000) } else { today @@ -409,11 +348,7 @@ fun lastSeenSentence( } } - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } + updateFormattersIfNeeded() val dateText = yearFormatter.format(time * 1000) return stringRes(context, R.string.last_seen_on_date, dateText, durationText) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt index 7c779d1f36..4bab7562ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.expiration +import android.text.format.DateFormat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -83,15 +84,14 @@ fun ExpirationDatePicker(model: IExpiration) { }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - Column(Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt index 8e08a138e4..ee67d55a78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls import android.annotation.SuppressLint +import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -81,15 +82,14 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) { } }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, // Set to true if you prefer military time + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt index e2c981f9e8..41cb41b86a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.zappolls import android.annotation.SuppressLint +import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -81,15 +82,14 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) { } }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, // Set to true if you prefer military time + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt index a5bafcb6af..26579bcf56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -63,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.formatMediumDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -76,9 +78,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Attes import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Preview @Composable @@ -188,18 +187,19 @@ fun RenderAttestation( } if (validFrom != null || validTo != null) { + val context = LocalContext.current Spacer(modifier = DoubleVertSpacer) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { validFrom?.let { Text( - text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)), + text = stringRes(R.string.attestation_valid_from, formatMediumDate(it, context)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } validTo?.let { Text( - text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)), + text = stringRes(R.string.attestation_valid_to, formatMediumDate(it, context)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -601,8 +601,3 @@ private fun attestationStatusLabel(status: AttestationStatus?): String = status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) else -> stringRes(R.string.attestation) } - -private fun formatTimestamp(timestamp: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - return sdf.format(Date(timestamp * 1000)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index a937a04db2..6cd1e7fa29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -164,6 +165,7 @@ private fun DayRow( } val startSeconds = view.startSeconds + val context = LocalContext.current val timeLabel = when { view.isAllDay -> stringRes(R.string.calendar_all_day) @@ -172,7 +174,7 @@ private fun DayRow( // misleading on day 2 since the event has been ongoing overnight. Show a // continuation marker so the user reads it as "still happening". stringRes(R.string.calendar_continues) - startSeconds != null -> formatTimeOfDay(startSeconds) + startSeconds != null -> formatTimeOfDay(startSeconds, context) else -> "—" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index e3f0599e8e..8af8a0269e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -72,8 +72,8 @@ fun CalendarEventListCard( modifier: Modifier = Modifier, ) { val view = note.appointmentView() ?: return - val range = remember(note.idHex) { formatCalendarRange(note) } val context = LocalContext.current + val range = remember(note.idHex) { formatCalendarRange(note, context) } val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) } val event = note.event ?: return val detailRoute = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt index f24a7cd20a..1a496bb142 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars +import android.content.Context +import android.text.format.DateFormat import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.model.Note import java.text.SimpleDateFormat @@ -27,34 +29,56 @@ import java.util.Calendar import java.util.Date import java.util.Locale -private val DayMonthFormat = SimpleDateFormat("EEE, MMM d", Locale.getDefault()) -private val FullDateFormat = SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()) -private val MonthYearFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) -private val TimeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) -private val WeekdayShortFormat = SimpleDateFormat("EEE", Locale.getDefault()) +// Skeletons follow Unicode LDML — getBestDateTimePattern picks locale ordering. +private const val DAY_MONTH_SKELETON = "EEEMMMd" // "Mon, May 28" / "Mon 28 May" +private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" / "Monday, 28 May 2026" +private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026" +private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter -fun formatCalendarRange(note: Note): String? { +private fun bestPattern(skeleton: String): SimpleDateFormat { + val locale = Locale.getDefault() + return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale) +} + +private fun dayMonthFormat() = bestPattern(DAY_MONTH_SKELETON) + +private fun fullDateFormat() = bestPattern(FULL_DATE_SKELETON) + +private fun monthYearFormat() = bestPattern(MONTH_YEAR_SKELETON) + +private fun weekdayShortFormat() = bestPattern(WEEKDAY_SHORT_SKELETON) + +// Time format respects the user's Android 12/24-hour system setting. +private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context) + +fun formatCalendarRange( + note: Note, + context: Context, +): String? { val view = note.appointmentView() ?: return null val start = view.startSeconds ?: return null return if (view.isAllDay) { formatDateRange(start, view.endSeconds) } else { - formatTimeRange(start, view.endSeconds) + formatTimeRange(start, view.endSeconds, context) } } private fun formatTimeRange( start: Long, end: Long?, + context: Context, ): String { + val dayMonth = dayMonthFormat() + val time = timeFormat(context) val startMs = start * 1000 - val startStr = "${DayMonthFormat.format(Date(startMs))} · ${TimeFormat.format(Date(startMs))}" + val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}" if (end == null || end == start) return startStr val endMs = end * 1000 return if (isSameDay(startMs, endMs)) { - "$startStr – ${TimeFormat.format(Date(endMs))}" + "$startStr – ${time.format(Date(endMs))}" } else { - "$startStr – ${DayMonthFormat.format(Date(endMs))} · ${TimeFormat.format(Date(endMs))}" + "$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}" } } @@ -62,12 +86,13 @@ private fun formatDateRange( start: Long, end: Long?, ): String { - val startStr = DayMonthFormat.format(Date(start * 1000)) + val dayMonth = dayMonthFormat() + val startStr = dayMonth.format(Date(start * 1000)) if (end == null || end == start) return startStr - return "$startStr – ${DayMonthFormat.format(Date(end * 1000))}" + return "$startStr – ${dayMonth.format(Date(end * 1000))}" } -fun formatLongDate(unixSeconds: Long): String = FullDateFormat.format(Date(unixSeconds * 1000)) +fun formatLongDate(unixSeconds: Long): String = fullDateFormat().format(Date(unixSeconds * 1000)) fun formatMonthYear( year: Int, @@ -76,10 +101,13 @@ fun formatMonthYear( val cal = Calendar.getInstance() cal.clear() cal.set(year, monthZeroBased, 1) - return MonthYearFormat.format(cal.time) + return monthYearFormat().format(cal.time) } -fun formatTimeOfDay(unixSeconds: Long): String = TimeFormat.format(Date(unixSeconds * 1000)) +fun formatTimeOfDay( + unixSeconds: Long, + context: Context, +): String = timeFormat(context).format(Date(unixSeconds * 1000)) fun formatShortWeekday(weekdayZeroBased: Int): String { val cal = Calendar.getInstance() @@ -87,7 +115,7 @@ fun formatShortWeekday(weekdayZeroBased: Int): String { cal.firstDayOfWeek = Calendar.SUNDAY cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY) cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased) - return WeekdayShortFormat.format(cal.time) + return weekdayShortFormat().format(cal.time) } private fun isSameDay( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt index c9c6f48cde..b90bac387e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt @@ -38,14 +38,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import java.text.DateFormat -import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset import java.util.Date +import android.text.format.DateFormat as AndroidDateFormat /** * Tap-to-edit button that opens a Material3 DatePicker (and, when [includeTime] is true, @@ -68,6 +69,7 @@ fun CalendarDateTimePickerButton( var showTime by remember { mutableStateOf(false) } val locale = LocalConfiguration.current.locales[0] + val context = LocalContext.current val pretty = if (unixSeconds <= 0L) { placeholder @@ -76,7 +78,7 @@ fun CalendarDateTimePickerButton( .getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale) .format(Date(unixSeconds * 1000)) } else { - SimpleDateFormat("EEEE, MMMM d, yyyy", locale).format(Date(unixSeconds * 1000)) + DateFormat.getDateInstance(DateFormat.FULL, locale).format(Date(unixSeconds * 1000)) } val initialMillis = if (unixSeconds > 0L) unixSeconds * 1000L else System.currentTimeMillis() @@ -94,7 +96,7 @@ fun CalendarDateTimePickerButton( rememberTimePickerState( initialHour = initialLocal.hour, initialMinute = initialLocal.minute, - is24Hour = false, + is24Hour = AndroidDateFormat.is24HourFormat(context), ) fun reset() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index e7de2dd865..7d68d70882 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -318,14 +318,14 @@ private fun EventBody( fontWeight = FontWeight.Bold, ) } - formatCalendarRange(note)?.let { range -> + val context = LocalContext.current + formatCalendarRange(note, context)?.let { range -> Text( text = range, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary, ) } - val context = LocalContext.current val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index 35df6bc622..532d72d777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBri import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch +import android.text.format.DateFormat as AndroidDateFormat /** * Bottom sheet that lets the logged-in user start a new NIP-53 kind 30312 @@ -248,6 +249,8 @@ private fun ScheduleStartPicker( var showDate by remember { mutableStateOf(false) } var showTime by remember { mutableStateOf(false) } + val context = LocalContext.current + val pretty = if (unixSeconds <= 0L) { stringRes(R.string.nest_create_when) @@ -277,7 +280,7 @@ private fun ScheduleStartPicker( androidx.compose.material3.rememberTimePickerState( initialHour = initialLocal.hour, initialMinute = initialLocal.minute, - is24Hour = false, + is24Hour = AndroidDateFormat.is24HourFormat(context), ) // On dismiss (Cancel or back-press), restore the picker states diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt index a83dea75f5..6971c82f94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt @@ -69,15 +69,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDate import com.vitorpamplona.amethyst.ui.note.timeAgoNoDotNoDay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun EventSyncScreen( @@ -750,6 +748,8 @@ private fun DateRangeFilterCard( LaunchedEffect(effectiveSince) { onSinceChanged(effectiveSince) } LaunchedEffect(effectiveUntil) { onUntilChanged(effectiveUntil) } + val context = LocalContext.current + Card( modifier = Modifier.fillMaxWidth(), @@ -775,7 +775,7 @@ private fun DateRangeFilterCard( if (effectiveSince == null) { stringRes(R.string.event_sync_date_filter_all_time) } else { - stringRes(R.string.event_sync_date_filter_since) + " " + formatEpochDate(sinceEpoch) + stringRes(R.string.event_sync_date_filter_since) + " " + formatMediumDate(sinceEpoch, context) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, @@ -785,7 +785,7 @@ private fun DateRangeFilterCard( if (effectiveUntil == null) { stringRes(R.string.event_sync_date_filter_now) } else { - stringRes(R.string.event_sync_date_filter_until) + " " + formatEpochDate(untilEpoch) + stringRes(R.string.event_sync_date_filter_until) + " " + formatMediumDate(untilEpoch, context) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, @@ -810,11 +810,6 @@ private fun DateRangeFilterCard( } } -private fun formatEpochDate(epochSecs: Long): String { - val sdf = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) - return sdf.format(Date(epochSecs * 1000)) -} - // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index c63a49f64c..7adc705e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish +import android.text.format.DateFormat import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -58,6 +59,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -72,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -85,12 +88,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList -import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset -import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -131,11 +131,12 @@ fun RequestToVanishScreen( val currentTime = Instant.ofEpochMilli(vanishDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, + is24Hour = DateFormat.is24HourFormat(context), ) val relayOptions = @@ -288,7 +289,7 @@ fun RequestToVanishScreen( ) Spacer(Modifier.width(12.dp)) Text( - text = formatTimestamp(vanishDate), + text = formatMediumDateTime(vanishDate, context), style = MaterialTheme.typography.bodyLarge, ) } @@ -461,11 +462,6 @@ private fun ConfirmVanishDialog( ) } -private fun formatTimestamp(epochSeconds: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) - return sdf.format(Date(epochSeconds * 1000)) -} - @Preview @Composable fun RequestToVanishScreenPreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt index 69f9549c7d..6909ecb491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,15 +59,13 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.ComplianceStatus import com.vitorpamplona.amethyst.model.nip62Vanish.VanishEventItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun VanishEventsScreen( @@ -170,6 +169,7 @@ private fun VanishEventCard( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), ) { + val context = LocalContext.current Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -182,7 +182,7 @@ private fun VanishEventCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = formatTimestamp(item.event.createdAt), + text = formatMediumDateTime(item.event.createdAt, context), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) @@ -355,8 +355,3 @@ private fun RelayComplianceRow( } } } - -private fun formatTimestamp(epochSeconds: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) - return sdf.format(Date(epochSeconds * 1000)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index cf49f27d32..0d3f1d9f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -60,6 +60,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend @@ -79,8 +81,6 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConf import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale /** @@ -497,10 +497,10 @@ private fun DiagnosticCard( // Last test timestamp if (lastTestTimestamp != null) { + val context = LocalContext.current val formatted = - remember(lastTestTimestamp) { - SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - .format(Date(lastTestTimestamp)) + remember(lastTestTimestamp, context) { + formatMediumDateTime(lastTestTimestamp / 1000L, context) } val successCount = testResults.count { it.success } val totalCount = testResults.size diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt index 72181d1936..59f45d0802 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.font.FontFamily @@ -65,15 +66,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.math.absoluteValue @OptIn(ExperimentalMaterial3Api::class) @@ -299,14 +298,11 @@ private fun OnchainTransactionItem( (if (isIncoming) "+" else "-") + fmt.format(amountSats) } + val context = LocalContext.current val dateText = - remember(view.tx.blockTime, view.tx.confirmations) { + remember(view.tx.blockTime, view.tx.confirmations, context) { val ts = view.tx.blockTime - if (ts != null) { - SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(Date(ts * 1000L)) - } else { - "" - } + if (ts != null) formatMonthDayTime(ts, context) else "" } val counterpartyPubkeyHex = view.counterpartyPubkeyHex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index d662e3c6d4..4e0b8be67d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -60,15 +61,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -238,12 +237,10 @@ private fun TransactionItem( (if (isIncoming) "+" else "-") + fmt.format(amountSats) } + val context = LocalContext.current val dateText = - remember(tx.created_at) { - tx.created_at?.let { - val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) - sdf.format(Date(it * 1000L)) - } ?: "" + remember(tx.created_at, context) { + tx.created_at?.let { formatMonthDayTime(it, context) } ?: "" } val parsed = remember(tx.metadata) { tx.parsedMetadata() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 3234edb759..3577b46bf5 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -27,18 +27,22 @@ import java.util.Calendar import java.util.Date import java.util.Locale -private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" -private const val MONTH_DATE_FORMAT = "MMM dd" +// Month + day without year — month name is textual so order is unambiguous across locales. +private const val MONTH_DATE_FORMAT = "MMM d" private var locale = Locale.getDefault() -private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private var yearFormatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + +// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32" private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) private fun updateFormattersIfNeeded() { if (locale != Locale.getDefault()) { locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + yearFormatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) } @@ -154,9 +158,9 @@ fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) /** * Formats a Unix timestamp (seconds) as an absolute date/time string. Granularity * depends on how far in the past the timestamp is: - * - same day → time only (e.g. "14:32"), locale-aware - * - same year → "MMM dd, HH:mm" - * - older → "MMM dd, yyyy" + * - same day → locale-aware short time (e.g. "14:32" / "2:32 PM") + * - same year → month + day + short time (e.g. "May 28, 2:32 PM") + * - older → locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026") */ fun timeAbsolute( time: Long?, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index 2b2249b92d..32e0479e63 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -97,12 +97,12 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date import java.util.Locale -private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val articleDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) /** * Parses a NIP-23 address tag in the format "30023:pubkey:d-tag". @@ -352,11 +352,7 @@ fun ArticleReaderScreen( val publishedAt = article?.let { art -> val ts = art.publishedAt() ?: art.createdAt - Instant - .ofEpochSecond(ts) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(articleDateFormat) + articleDateFormat.format(Date(ts * 1000L)) } // Author info from local cache diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 0706f93fb6..7f55849d65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -68,19 +68,14 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date import java.util.Locale -private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) -private fun formatDate(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(dateFormat) +private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000L)) /** * Card displaying long-form content (NIP-23) with title, summary, and image. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index 8fbc168f55..a2335cc594 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -58,9 +58,9 @@ import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date +import java.util.Locale @Composable fun MyHighlightsScreen( @@ -236,8 +236,8 @@ private fun HighlightCard( } } -private fun formatTimestamp(epochSeconds: Long): String = - Instant - .ofEpochSecond(epochSeconds) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) +// Locale-aware date+time, e.g. en-US "May 28, 2026 2:32 PM" · de-DE "28.05.2026 14:32". +private val dateTimeFormat = + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) + +private fun formatTimestamp(epochSeconds: Long): String = dateTimeFormat.format(Date(epochSeconds * 1000L)) From 599fa2aa538b2ef82146f6c1cba429d2e3a87ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:24:33 +0000 Subject: [PATCH 14/51] docs: restore canonical NIPs link in CLAUDE.md Re-add the nostr-protocol/nips reference (dropped in the cleanup) as a one-liner in the overview, tied to the /nip command so it points at the exact spec file rather than the bare index. --- .claude/CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 760300654a..7b4b39125b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -20,6 +20,9 @@ nostrnests reference relay. The IETF code is kept as a reference + unit-test implementation for any future IETF target; see `nestsClient/plans/2026-04-26-moq-lite-gap.md`. +Canonical NIP specs live at — use +`/nip ` to pull a specific one (it fetches the spec file directly). + ## Verify, Don't Guess (standing instruction) A plausible-sounding explanation is cheap; being right is not. Before From cbd8cd1b87b8ac58fa59cafa8c12b536ee8de7d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:46:29 +0000 Subject: [PATCH 15/51] ci: switch setup-java distribution from zulu to temurin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zulu's CDN (cdn.azul.com) has been returning HTTP 520 for several hours, breaking JDK 21 setup across multiple PRs (#3075, #3095) on every Linux runner. actions/setup-java's internal retry budget isn't enough to ride through it. Temurin (Adoptium) publishes its JDKs as GitHub Releases assets, so the download path uses GitHub's own CDN — completely independent infrastructure. Java 21 is the same upstream OpenJDK build either way, so Gradle, Compose Multiplatform, and jpackage behave identically. Applied to all setup-java steps in build.yml, smoke-test-desktop.yml, and create-release.yml. --- .github/workflows/build.yml | 8 ++++---- .github/workflows/create-release.yml | 6 +++--- .github/workflows/smoke-test-desktop.yml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9ed078f30..5997aaaed0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 # Remote Gradle build cache: writes on push to main, reads on PRs and @@ -71,7 +71,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle @@ -203,7 +203,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle @@ -256,7 +256,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 17b7b64c13..7c5cdf1b47 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -55,7 +55,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Resolve tag + version @@ -223,7 +223,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Resolve tag + version @@ -346,7 +346,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Cache gradle diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 5e1d7325ca..b5eb3a1355 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -30,7 +30,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle @@ -60,7 +60,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle From c63c48a7787211059cd2dbb56cafa2ab19aab924 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:47:55 +0000 Subject: [PATCH 16/51] Cache CalendarTimeFormat formatters at module scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-refactor `bestPattern(skeleton)` helper allocated a fresh SimpleDateFormat — plus ran the ICU lookup in `getBestDateTimePattern` — on every call. That's hot in the calendar grid: 7 weekday-header calls per render and up to 42 `formatLongDate` cells per month render. Restore the old caching pattern (module-level vars, rebuild on locale change via `refreshFormattersIfLocaleChanged`), now keyed off the locale-aware skeleton patterns. Time format still re-fetched per call via `DateFormat.getTimeFormat(context)` since the user's 24-hour override can change without a locale change. --- .../loggedIn/calendars/CalendarTimeFormat.kt | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt index 1a496bb142..91140aecd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -35,19 +35,31 @@ private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" / private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026" private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter -private fun bestPattern(skeleton: String): SimpleDateFormat { - val locale = Locale.getDefault() - return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale) +// Cached at module scope: these are called per-cell in the calendar grid (≈42 cells +// per month render, 7 per weekday header) — allocating a fresh SimpleDateFormat on +// every call showed up as scroll-time GC churn before caching. +private var cachedLocale: Locale = Locale.getDefault() +private var dayMonthFormat = build(DAY_MONTH_SKELETON, cachedLocale) +private var fullDateFormat = build(FULL_DATE_SKELETON, cachedLocale) +private var monthYearFormat = build(MONTH_YEAR_SKELETON, cachedLocale) +private var weekdayShortFormat = build(WEEKDAY_SHORT_SKELETON, cachedLocale) + +private fun build( + skeleton: String, + locale: Locale, +): SimpleDateFormat = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale) + +private fun refreshFormattersIfLocaleChanged() { + val current = Locale.getDefault() + if (cachedLocale != current) { + cachedLocale = current + dayMonthFormat = build(DAY_MONTH_SKELETON, current) + fullDateFormat = build(FULL_DATE_SKELETON, current) + monthYearFormat = build(MONTH_YEAR_SKELETON, current) + weekdayShortFormat = build(WEEKDAY_SHORT_SKELETON, current) + } } -private fun dayMonthFormat() = bestPattern(DAY_MONTH_SKELETON) - -private fun fullDateFormat() = bestPattern(FULL_DATE_SKELETON) - -private fun monthYearFormat() = bestPattern(MONTH_YEAR_SKELETON) - -private fun weekdayShortFormat() = bestPattern(WEEKDAY_SHORT_SKELETON) - // Time format respects the user's Android 12/24-hour system setting. private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context) @@ -69,16 +81,16 @@ private fun formatTimeRange( end: Long?, context: Context, ): String { - val dayMonth = dayMonthFormat() + refreshFormattersIfLocaleChanged() val time = timeFormat(context) val startMs = start * 1000 - val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}" + val startStr = "${dayMonthFormat.format(Date(startMs))} · ${time.format(Date(startMs))}" if (end == null || end == start) return startStr val endMs = end * 1000 return if (isSameDay(startMs, endMs)) { "$startStr – ${time.format(Date(endMs))}" } else { - "$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}" + "$startStr – ${dayMonthFormat.format(Date(endMs))} · ${time.format(Date(endMs))}" } } @@ -86,22 +98,26 @@ private fun formatDateRange( start: Long, end: Long?, ): String { - val dayMonth = dayMonthFormat() - val startStr = dayMonth.format(Date(start * 1000)) + refreshFormattersIfLocaleChanged() + val startStr = dayMonthFormat.format(Date(start * 1000)) if (end == null || end == start) return startStr - return "$startStr – ${dayMonth.format(Date(end * 1000))}" + return "$startStr – ${dayMonthFormat.format(Date(end * 1000))}" } -fun formatLongDate(unixSeconds: Long): String = fullDateFormat().format(Date(unixSeconds * 1000)) +fun formatLongDate(unixSeconds: Long): String { + refreshFormattersIfLocaleChanged() + return fullDateFormat.format(Date(unixSeconds * 1000)) +} fun formatMonthYear( year: Int, monthZeroBased: Int, ): String { + refreshFormattersIfLocaleChanged() val cal = Calendar.getInstance() cal.clear() cal.set(year, monthZeroBased, 1) - return monthYearFormat().format(cal.time) + return monthYearFormat.format(cal.time) } fun formatTimeOfDay( @@ -110,12 +126,13 @@ fun formatTimeOfDay( ): String = timeFormat(context).format(Date(unixSeconds * 1000)) fun formatShortWeekday(weekdayZeroBased: Int): String { + refreshFormattersIfLocaleChanged() val cal = Calendar.getInstance() cal.clear() cal.firstDayOfWeek = Calendar.SUNDAY cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY) cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased) - return weekdayShortFormat().format(cal.time) + return weekdayShortFormat.format(cal.time) } private fun isSameDay( From aeadbbd276f199ccc7392373c0d40b00989c6f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 23:00:03 +0000 Subject: [PATCH 17/51] Make TimeAgoFormatter + CalendarTimeFormat thread-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module-level mutable SimpleDateFormat formatters in these files were read concurrently — UI composition on the main thread, and LocalCache.justVerify calling dateFormatter() from background event- verification coroutines for failed-signature log lines. SimpleDateFormat is not thread-safe (mutable internal Calendar), and updateFormattersIfNeeded reassigned the field mid-format. Race produced corrupted timestamp strings and occasionally NumberFormatException inside format(). Replace the shared-var pattern with a small LocaleAwareFormatter that wraps a ThreadLocal>. Each thread caches its own instance and rebuilds lazily when Locale.getDefault() changes — no locks, no contention, same allocation profile after warm-up. Apply the same pattern to CalendarTimeFormat for consistency; today its callers are all main-thread but the structure was identical. --- .../amethyst/ui/note/TimeAgoFormatter.kt | 75 ++++++++++--------- .../loggedIn/calendars/CalendarTimeFormat.kt | 70 ++++++++--------- .../amethyst/commons/util/TimeAgoFormatter.kt | 60 ++++++++------- 3 files changed, 106 insertions(+), 99 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 1491014263..6e3305263f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -38,21 +38,35 @@ private const val YEAR_SKELETON = "yMMMd" private const val MONTH_SKELETON = "MMMd" private const val YEAR_NO_DAY_SKELETON = "yMMM" -private var locale: Locale = Locale.getDefault() -private var yearFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_SKELETON), locale) -private var monthFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, MONTH_SKELETON), locale) -private var yearNoDayFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_NO_DAY_SKELETON), locale) +/** + * Per-thread cached [SimpleDateFormat] keyed off the current default [Locale]. + * + * `SimpleDateFormat` is mutable and not thread-safe, and these formatters are + * read from both the UI thread (composition) and background coroutines + * (e.g. `LocalCache.justVerify` logging failed event verifications). A bare + * `var` shared across threads would race on the formatter's internal Calendar. + * Using `ThreadLocal` gives each thread its own instance — no locks, no + * allocation per call, and we rebuild lazily on locale change. + */ +private class LocaleAwareFormatter( + private val skeleton: String, +) { + private val cache = ThreadLocal>() -private fun updateFormattersIfNeeded() { - val current = Locale.getDefault() - if (locale != current) { - locale = current - yearFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_SKELETON), locale) - monthFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, MONTH_SKELETON), locale) - yearNoDayFormatter = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, YEAR_NO_DAY_SKELETON), locale) + fun get(): SimpleDateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current) + cache.set(current to fresh) + return fresh } } +private val yearFormatter = LocaleAwareFormatter(YEAR_SKELETON) +private val monthFormatter = LocaleAwareFormatter(MONTH_SKELETON) +private val yearNoDayFormatter = LocaleAwareFormatter(YEAR_NO_DAY_SKELETON) + /** * Formats a Unix timestamp (seconds) as an absolute date/time string, picking the * granularity from how far away the timestamp is: @@ -71,8 +85,6 @@ fun timeAbsolute( if (time == null) return " " if (time == 0L) return prefix + stringRes(context, R.string.never) - updateFormattersIfNeeded() - val timeMs = time * 1000 val now = Calendar.getInstance() val then = Calendar.getInstance().apply { timeInMillis = timeMs } @@ -84,8 +96,8 @@ fun timeAbsolute( return when { sameDay -> prefix + timeOfDay - sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay - else -> prefix + yearFormatter.format(timeMs) + sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.get().format(timeMs) } } @@ -110,13 +122,11 @@ fun timeAgo( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - prefix + yearFormatter.format(time * 1000) + prefix + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - updateFormattersIfNeeded() - prefix + monthFormatter.format(time * 1000) + prefix + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -148,13 +158,11 @@ fun timeAgoNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - updateFormattersIfNeeded() - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -186,13 +194,11 @@ fun timeAgoNoDotNoDay( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - yearNoDayFormatter.format(time * 1000) + yearNoDayFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - updateFormattersIfNeeded() - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -224,13 +230,11 @@ fun timeAheadNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - updateFormattersIfNeeded() - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -262,11 +266,9 @@ fun dateFormatter( val timeDifference = TimeUtils.now() - time return if (timeDifference > TimeUtils.ONE_YEAR) { - updateFormattersIfNeeded() - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } else if (timeDifference > TimeUtils.ONE_DAY) { - updateFormattersIfNeeded() - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } else { today } @@ -348,8 +350,7 @@ fun lastSeenSentence( } } - updateFormattersIfNeeded() - val dateText = yearFormatter.format(time * 1000) + val dateText = yearFormatter.get().format(time * 1000) return stringRes(context, R.string.last_seen_on_date, dateText, durationText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt index 91140aecd0..2738041aa9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -35,31 +35,36 @@ private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" / private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026" private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter -// Cached at module scope: these are called per-cell in the calendar grid (≈42 cells -// per month render, 7 per weekday header) — allocating a fresh SimpleDateFormat on -// every call showed up as scroll-time GC churn before caching. -private var cachedLocale: Locale = Locale.getDefault() -private var dayMonthFormat = build(DAY_MONTH_SKELETON, cachedLocale) -private var fullDateFormat = build(FULL_DATE_SKELETON, cachedLocale) -private var monthYearFormat = build(MONTH_YEAR_SKELETON, cachedLocale) -private var weekdayShortFormat = build(WEEKDAY_SHORT_SKELETON, cachedLocale) +/** + * Per-thread cached [SimpleDateFormat] keyed off the current default [Locale]. + * + * Cached because these run per cell in the calendar grid (≈42 cells per month, + * 7 per weekday header) — allocating a fresh SimpleDateFormat each call + * showed up as scroll-time GC churn. Per-thread because SimpleDateFormat + * isn't thread-safe; calendars today are read on the UI thread but future + * callers could differ. ThreadLocal gives both — no locks, no contention, + * lazy rebuild on locale change. + */ +private class LocaleAwareFormatter( + private val skeleton: String, +) { + private val cache = ThreadLocal>() -private fun build( - skeleton: String, - locale: Locale, -): SimpleDateFormat = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale) - -private fun refreshFormattersIfLocaleChanged() { - val current = Locale.getDefault() - if (cachedLocale != current) { - cachedLocale = current - dayMonthFormat = build(DAY_MONTH_SKELETON, current) - fullDateFormat = build(FULL_DATE_SKELETON, current) - monthYearFormat = build(MONTH_YEAR_SKELETON, current) - weekdayShortFormat = build(WEEKDAY_SHORT_SKELETON, current) + fun get(): SimpleDateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current) + cache.set(current to fresh) + return fresh } } +private val dayMonthFormat = LocaleAwareFormatter(DAY_MONTH_SKELETON) +private val fullDateFormat = LocaleAwareFormatter(FULL_DATE_SKELETON) +private val monthYearFormat = LocaleAwareFormatter(MONTH_YEAR_SKELETON) +private val weekdayShortFormat = LocaleAwareFormatter(WEEKDAY_SHORT_SKELETON) + // Time format respects the user's Android 12/24-hour system setting. private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context) @@ -81,16 +86,16 @@ private fun formatTimeRange( end: Long?, context: Context, ): String { - refreshFormattersIfLocaleChanged() + val dayMonth = dayMonthFormat.get() val time = timeFormat(context) val startMs = start * 1000 - val startStr = "${dayMonthFormat.format(Date(startMs))} · ${time.format(Date(startMs))}" + val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}" if (end == null || end == start) return startStr val endMs = end * 1000 return if (isSameDay(startMs, endMs)) { "$startStr – ${time.format(Date(endMs))}" } else { - "$startStr – ${dayMonthFormat.format(Date(endMs))} · ${time.format(Date(endMs))}" + "$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}" } } @@ -98,26 +103,22 @@ private fun formatDateRange( start: Long, end: Long?, ): String { - refreshFormattersIfLocaleChanged() - val startStr = dayMonthFormat.format(Date(start * 1000)) + val dayMonth = dayMonthFormat.get() + val startStr = dayMonth.format(Date(start * 1000)) if (end == null || end == start) return startStr - return "$startStr – ${dayMonthFormat.format(Date(end * 1000))}" + return "$startStr – ${dayMonth.format(Date(end * 1000))}" } -fun formatLongDate(unixSeconds: Long): String { - refreshFormattersIfLocaleChanged() - return fullDateFormat.format(Date(unixSeconds * 1000)) -} +fun formatLongDate(unixSeconds: Long): String = fullDateFormat.get().format(Date(unixSeconds * 1000)) fun formatMonthYear( year: Int, monthZeroBased: Int, ): String { - refreshFormattersIfLocaleChanged() val cal = Calendar.getInstance() cal.clear() cal.set(year, monthZeroBased, 1) - return monthYearFormat.format(cal.time) + return monthYearFormat.get().format(cal.time) } fun formatTimeOfDay( @@ -126,13 +127,12 @@ fun formatTimeOfDay( ): String = timeFormat(context).format(Date(unixSeconds * 1000)) fun formatShortWeekday(weekdayZeroBased: Int): String { - refreshFormattersIfLocaleChanged() val cal = Calendar.getInstance() cal.clear() cal.firstDayOfWeek = Calendar.SUNDAY cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY) cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased) - return weekdayShortFormat.format(cal.time) + return weekdayShortFormat.get().format(cal.time) } private fun isSameDay( diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 3577b46bf5..43f6d5270c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -30,24 +30,36 @@ import java.util.Locale // Month + day without year — month name is textual so order is unambiguous across locales. private const val MONTH_DATE_FORMAT = "MMM d" -private var locale = Locale.getDefault() +/** + * Per-thread cached [DateFormat] keyed off the current default [Locale]. + * + * `DateFormat`/`SimpleDateFormat` are mutable and not thread-safe. These + * formatters can be read from UI threads (composition) and from background + * coroutines. `ThreadLocal` gives each thread its own instance — no locks, + * no allocation per call, and we rebuild lazily on locale change. + */ +private class LocaleAwareFormatter( + private val build: (Locale) -> DateFormat, +) { + private val cache = ThreadLocal>() -// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" -private var yearFormatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) -private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - -// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32" -private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) - -private fun updateFormattersIfNeeded() { - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) + fun get(): DateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = build(current) + cache.set(current to fresh) + return fresh } } +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val yearFormatter = LocaleAwareFormatter { DateFormat.getDateInstance(DateFormat.MEDIUM, it) } +private val monthFormatter = LocaleAwareFormatter { SimpleDateFormat(MONTH_DATE_FORMAT, it) } + +// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32" +private val timeOnlyFormatter = LocaleAwareFormatter { DateFormat.getTimeInstance(DateFormat.SHORT, it) } + /** * Formats a Unix timestamp (seconds) as a human-readable time ago string. * Returns strings like " • 5m", " • 2h", " • Dec 12" @@ -68,13 +80,11 @@ fun timeAgo( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - prefix + yearFormatter.format(time * 1000) + prefix + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - updateFormattersIfNeeded() - prefix + monthFormatter.format(time * 1000) + prefix + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -135,13 +145,11 @@ fun dateFormatter( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { - updateFormattersIfNeeded() - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } else -> { @@ -171,8 +179,6 @@ fun timeAbsolute( val prefix = if (withDot) " • " else "" if (time == 0L) return prefix + never - updateFormattersIfNeeded() - val timeMs = time * 1000 val now = Calendar.getInstance() val then = Calendar.getInstance().apply { timeInMillis = timeMs } @@ -180,12 +186,12 @@ fun timeAbsolute( val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR) val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR) - val timeOfDay = timeOnlyFormatter.format(Date(timeMs)) + val timeOfDay = timeOnlyFormatter.get().format(Date(timeMs)) return when { sameDay -> prefix + timeOfDay - sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay - else -> prefix + yearFormatter.format(timeMs) + sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.get().format(timeMs) } } From 02fd0780d5653f892ac6db7625783311ff52223f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 23:01:49 +0000 Subject: [PATCH 18/51] fix(feeds): don't snap to top when returning from a post screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StickToTopOnPrepend hardcoded `wasAtTop = true` on remember, so when the user scrolled down, navigated to a post and came back, rememberForeverLazyListState restored a non-zero offset but the helper still believed the user was at the top. As soon as the head-of-feed key emitted (first observation after recomposition), the LaunchedEffect scrolled them back to 0. Seed wasAtTop from the actual restored scroll position via an initialAtTop lambda. The sampler's gesture-guarded false→true protection against keyed-item shifts is unchanged. Regression from #3088. --- .../amethyst/ui/feeds/WatchScrollToTop.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index 8f27178ddf..ddccd7e96d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -137,6 +137,9 @@ fun StickToTopOnPrepend( stickToTopOnPrepend( stateKey = listState, firstItemKey = firstItemKey, + initialAtTop = { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + }, sampler = { snapshotFlow { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 @@ -156,6 +159,9 @@ fun StickToTopOnPrepend( stickToTopOnPrepend( stateKey = gridState, firstItemKey = firstItemKey, + initialAtTop = { + gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 + }, sampler = { snapshotFlow { gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 @@ -209,6 +215,7 @@ private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? private fun stickToTopOnPrepend( stateKey: Any, firstItemKey: Any?, + initialAtTop: () -> Boolean, sampler: () -> Flow, isScrollInProgress: () -> Boolean, firstVisibleItemIndex: () -> Int, @@ -216,7 +223,10 @@ private fun stickToTopOnPrepend( ) { // Plain holder instead of mutableStateOf — we only read this inside // effects, never in composition, so we don't need snapshot tracking. - val wasAtTop = remember { booleanArrayOf(true) } + // Seed from the actual restored scroll position: when the user returns + // to a feed via rememberForeverLazyListState, the saved offset is + // already in place, and a hardcoded `true` would mis-snap them to 0. + val wasAtTop = remember(stateKey) { booleanArrayOf(initialAtTop()) } LaunchedEffect(stateKey) { sampler().collect { atTop -> From b431c1efabbb618c855216fa1f6acfcbd3fe52a0 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 06:46:19 +0300 Subject: [PATCH 19/51] =?UTF-8?q?feat(desktop):=20visual=20personality=20o?= =?UTF-8?q?verhaul=20=E2=80=94=20unified=20theme,=20sidebar,=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Replace per-OS color schemes with unified Amethyst brand - Cyan/blue accent (#0096FF light, #4DB8FF dark) replacing OS-adaptive colors - Amethyst purple as tertiary heritage color - Unified shapes (8/12/16/24dp) replacing per-OS variants - Standardized typography weights (Light for display, SemiBold for headlines) - Letter spacing unified to -0.3sp Phase 2: Spacing system - AmethystSpacing CompositionLocal with design tokens - LocalIsDarkTheme for M3-compatible dark mode detection Phase 3: Sidebar redesign - 240dp wide sidebar with icon + text labels (was 56dp icon-only) - Animated collapse/expand with smooth width transition - Avatar + username at top with account switcher - Custom feeds section from FeedDefinitionRepository - Active item cyan pill indicator with hover effects - Collapse state persisted in Preferences - Debounced fitColumnsToWidth to prevent animation thrash Phase 4: Card refinement - OutlinedCard with 1dp border replacing 1dp shadow elevation - 16dp internal padding (was 12dp) - Converted NoteCard, ReadsScreen, DraftsScreen, MyHighlightsScreen, UserProfileScreen Phase 5: Column header restyling - 48dp height (was 40dp) with surfaceContainer background - 12dp horizontal padding (was 8dp) Phase 6: Polish - HoverModifiers.kt — shared hover highlight using onPointerEvent + drawBehind - ShimmerPlaceholder.kt — skeleton loading animation in commons/commonMain Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/components/ShimmerPlaceholder.kt | 96 ++++ .../amethyst/commons/ui/theme/Colors.kt | 4 + .../amethyst/desktop/DesktopPreferences.kt | 8 + .../vitorpamplona/amethyst/desktop/Main.kt | 12 + .../desktop/platform/PlatformColorScheme.kt | 328 +++---------- .../desktop/platform/PlatformShapes.kt | 70 +-- .../desktop/platform/PlatformTheme.kt | 24 +- .../desktop/platform/PlatformTypography.kt | 20 +- .../amethyst/desktop/ui/DraftsScreen.kt | 10 +- .../amethyst/desktop/ui/ReadsScreen.kt | 10 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 9 +- .../amethyst/desktop/ui/deck/ColumnHeader.kt | 6 +- .../amethyst/desktop/ui/deck/DeckLayout.kt | 28 +- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 464 ++++++++++++++++-- .../ui/highlights/MyHighlightsScreen.kt | 10 +- .../amethyst/desktop/ui/note/NoteCard.kt | 23 +- .../desktop/ui/theme/AmethystSpacing.kt | 53 ++ .../desktop/ui/theme/HoverModifiers.kt | 47 ++ 18 files changed, 820 insertions(+), 402 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt new file mode 100644 index 0000000000..0be7a93ab2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt @@ -0,0 +1,96 @@ +/* + * 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.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp + +@Composable +fun ShimmerPlaceholder(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmerTranslate", + ) + + val shimmerColors = + listOf( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + ) + + val brush = + Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 200f, translateAnim - 200f), + end = Offset(translateAnim, translateAnim), + ) + + Box(modifier.background(brush, MaterialTheme.shapes.small)) +} + +@Composable +fun NoteCardSkeleton(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp)) + ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp)) + } + } + ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp)) + ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp)) + ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt index 9f79b1d4f5..0c0a65da72 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.commons.ui.theme import androidx.compose.ui.graphics.Color // Primary brand colors +val AmethystBlue = Color(0xFF0096FF) +val AmethystBlueDark = Color(0xFF4DB8FF) +val AmethystPurple = Color(0xFF9A82DB) + val Primary50 = Color(red = 127, green = 103, blue = 190) val Primary60 = Color(red = 154, green = 130, blue = 219) val Primary70 = Color(red = 182, green = 157, blue = 248) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 00d8e26fab..92d5b123be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -105,4 +105,12 @@ object DesktopPreferences { val preferredBlossomServer: String get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + + private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed" + + var sidebarCollapsed: Boolean + get() = prefs.getBoolean(KEY_SIDEBAR_COLLAPSED, false) + set(value) { + prefs.putBoolean(KEY_SIDEBAR_COLLAPSED, value) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fef81a722b..294609c1d1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1381,6 +1381,10 @@ fun MainContent( val allAccountsState by accountManager.allAccounts.collectAsState() var showAddAccountDialog by remember { mutableStateOf(false) } + val deckColumns by deckState.columns.collectAsState() + val focusedIdx by deckState.focusedColumnIndex.collectAsState() + val activeColumnType = deckColumns.getOrNull(focusedIdx)?.type + DeckSidebar( activeNpub = accountManager.currentAccount()?.npub, allAccounts = allAccountsState, @@ -1404,6 +1408,14 @@ fun MainContent( deckState.addColumn(DeckColumnType.Settings) } }, + onNavigate = { type -> + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + }, + activeColumnType = activeColumnType, onShowImportFollowListDialog = onShowImportFollowListDialog, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt index 6a8c3557bf..bf77e481fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt @@ -24,272 +24,92 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlue +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlueDark +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPurple /** - * Per-OS Material3 [ColorScheme]s tuned to match each platform's native surface - * tones. The OS accent (resolved via [PlatformAccent]) is plumbed in as `primary` - * so links, focus rings, and selection highlights match the rest of the user's - * system. - * - * Surface tones come from each OS's reference palettes: - * - macOS dark: NSWindowBackgroundColor / NSAlternateSelectedControlColor. - * - macOS light: NSColor systemBackgroundColor (#FFFFFF) / window background (#ECECEC). - * - GNOME dark: libadwaita `@window_bg_color` (#242424) / `@view_bg_color` (#1E1E1E). - * - GNOME light: libadwaita `@window_bg_color` (#FAFAFA) / `@view_bg_color` (#FFFFFF). - * - KDE Breeze dark/light defaults. - * - Windows 11 dark/light: WinUI mica reference values. + * Unified Amethyst brand [ColorScheme] — consistent visual identity across all + * platforms. Uses cyan/blue (#0096FF) as primary accent, with Amethyst purple + * as tertiary heritage color. */ object PlatformColorScheme { - fun resolve( - dark: Boolean, - accent: Color, - ): ColorScheme = - when (PlatformInfo.current) { - Platform.MACOS -> if (dark) macOSDark(accent) else macOSLight(accent) - Platform.GNOME -> if (dark) gnomeDark(accent) else gnomeLight(accent) - Platform.KDE -> if (dark) kdeDark(accent) else kdeLight(accent) - Platform.WINDOWS -> if (dark) windowsDark(accent) else windowsLight(accent) - else -> if (dark) genericDark(accent) else genericLight(accent) - } + fun resolve(dark: Boolean): ColorScheme = if (dark) amethystDark() else amethystLight() - // ── macOS ───────────────────────────────────────────────────────────────── + private fun amethystLight() = + lightColorScheme( + primary = AmethystBlue, + onPrimary = Color.White, + primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White), + onPrimaryContainer = AmethystBlue, + secondary = Color(0xFF5E8FAD), + onSecondary = Color.White, + secondaryContainer = Color(0xFF5E8FAD).copy(alpha = 0.12f).compositeOver(Color.White), + onSecondaryContainer = Color(0xFF5E8FAD), + tertiary = AmethystPurple, + onTertiary = Color.White, + tertiaryContainer = AmethystPurple.copy(alpha = 0.12f).compositeOver(Color.White), + onTertiaryContainer = AmethystPurple, + background = Color(0xFFF2F2F7), + onBackground = Color(0xFF1C1C1E), + surface = Color.White, + onSurface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFFF0F0F5), + onSurfaceVariant = Color(0xFF6E6E73), + surfaceContainer = Color(0xFFF7F7FA), + surfaceContainerHigh = Color(0xFFEEEEF2), + surfaceContainerHighest = Color(0xFFE5E5EA), + surfaceContainerLow = Color(0xFFFAFAFC), + surfaceContainerLowest = Color.White, + surfaceDim = Color(0xFFE8E8ED), + surfaceBright = Color.White, + outline = Color(0xFFE0E0E0), + outlineVariant = Color(0xFFEBEBEB), + inverseSurface = Color(0xFF2C2C2E), + inverseOnSurface = Color(0xFFF2F2F7), + inversePrimary = AmethystBlueDark, + error = Color(0xFFBA1A1A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + ) - private fun macOSDark(accent: Color) = + private fun amethystDark() = darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1E1E1E), - onBackground = Color(0xFFE5E5E5), + primary = AmethystBlueDark, + onPrimary = Color.White, + primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onPrimaryContainer = AmethystBlueDark, + secondary = Color(0xFF7EAEC8), + onSecondary = Color.White, + secondaryContainer = Color(0xFF7EAEC8).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onSecondaryContainer = Color(0xFF7EAEC8), + tertiary = Color(0xFFB6A0E0), + onTertiary = Color.White, + tertiaryContainer = Color(0xFFB6A0E0).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onTertiaryContainer = Color(0xFFB6A0E0), + background = Color(0xFF121212), + onBackground = Color(0xFFE5E5EA), surface = Color(0xFF1E1E1E), - onSurface = Color(0xFFE5E5E5), + onSurface = Color(0xFFE5E5EA), surfaceVariant = Color(0xFF2A2A2A), - onSurfaceVariant = Color(0xFFB8B8B8), + onSurfaceVariant = Color(0xFF9E9EA3), surfaceContainer = Color(0xFF252525), - surfaceContainerHigh = Color(0xFF2D2D2D), - surfaceContainerHighest = Color(0xFF353535), + surfaceContainerHigh = Color(0xFF2E2E2E), + surfaceContainerHighest = Color(0xFF383838), surfaceContainerLow = Color(0xFF1A1A1A), surfaceContainerLowest = Color(0xFF141414), surfaceDim = Color(0xFF1A1A1A), - surfaceBright = Color(0xFF353535), - outline = Color(0xFF6A6A6A), - outlineVariant = Color(0xFF3A3A3A), + surfaceBright = Color(0xFF383838), + outline = Color(0xFF3A3A3A), + outlineVariant = Color(0xFF2E2E2E), + inverseSurface = Color(0xFFE5E5EA), + inverseOnSurface = Color(0xFF1C1C1E), + inversePrimary = AmethystBlue, + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), ) - - private fun macOSLight(accent: Color): ColorScheme { - // Apple's accent palette includes Yellow (#F8BA00) and Graphite (#8C8C8C) - // which have terrible contrast on any light surface. Darken high-luminance - // accents so the primary color stays readable without disabling the user's - // preference — the hue is preserved, just pulled toward a usable lightness. - val readable = darkenForLight(accent) - return lightColorScheme( - primary = readable, - onPrimary = onAccent(readable), - secondary = readable, - tertiary = readable, - // Apple's light-mode main content background is near-white (#F5F5F7 / - // Apple's secondarySystemBackgroundColor); the #ECECEC gray is only - // used behind the title bar chrome — which we map to surfaceContainer. - background = Color(0xFFF5F5F7), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFECECEC), - onSurfaceVariant = Color(0xFF555555), - surfaceContainer = Color(0xFFECECEC), - surfaceContainerHigh = Color(0xFFE5E5E5), - surfaceContainerHighest = Color(0xFFDDDDDD), - surfaceContainerLow = Color(0xFFF0F0F0), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD8D8D8), - ) - } - - /** - * Scales an accent color toward black until its relative luminance is at - * most [maxLuminance]. Preserves hue (all RGB channels scale by the same - * factor). Only applied on light surfaces — on dark surfaces the raw accent - * already has good contrast. - * - * 0.38 was picked empirically: it keeps a saturated blue visible without - * darkening it, while pulling yellow/graphite down to a readable amber/gray. - */ - private fun darkenForLight( - c: Color, - maxLuminance: Float = 0.38f, - ): Color { - val lum = 0.2126f * c.red + 0.7152f * c.green + 0.0722f * c.blue - if (lum <= maxLuminance) return c - val scale = maxLuminance / lum - return Color( - red = (c.red * scale).coerceIn(0f, 1f), - green = (c.green * scale).coerceIn(0f, 1f), - blue = (c.blue * scale).coerceIn(0f, 1f), - alpha = c.alpha, - ) - } - - // ── GNOME (libadwaita) ──────────────────────────────────────────────────── - - private fun gnomeDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF242424), - onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF1E1E1E), - onSurface = Color(0xFFFFFFFF), - surfaceVariant = Color(0xFF323232), - onSurfaceVariant = Color(0xFFCCCCCC), - surfaceContainer = Color(0xFF2C2C2C), - surfaceContainerHigh = Color(0xFF383838), - surfaceContainerHighest = Color(0xFF424242), - surfaceContainerLow = Color(0xFF222222), - surfaceContainerLowest = Color(0xFF1A1A1A), - outline = Color(0xFF5E5E5E), - outlineVariant = Color(0xFF3A3A3A), - ) - - private fun gnomeLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFFAFAFA), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFF0F0F0), - onSurfaceVariant = Color(0xFF5E5E5E), - surfaceContainer = Color(0xFFF4F4F4), - surfaceContainerHigh = Color(0xFFEDEDED), - surfaceContainerHighest = Color(0xFFE5E5E5), - surfaceContainerLow = Color(0xFFF9F9F9), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD4D4D4), - ) - - // ── KDE Plasma (Breeze) ─────────────────────────────────────────────────── - - private fun kdeDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1B1E20), - onBackground = Color(0xFFFCFCFC), - surface = Color(0xFF232629), - onSurface = Color(0xFFFCFCFC), - surfaceVariant = Color(0xFF2A2E32), - onSurfaceVariant = Color(0xFFBDC3C7), - surfaceContainer = Color(0xFF272A2E), - surfaceContainerHigh = Color(0xFF31353A), - surfaceContainerHighest = Color(0xFF3B4045), - surfaceContainerLow = Color(0xFF1F2225), - surfaceContainerLowest = Color(0xFF18191B), - outline = Color(0xFF4D5258), - outlineVariant = Color(0xFF34383C), - ) - - private fun kdeLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFEFF0F1), - onBackground = Color(0xFF232629), - surface = Color(0xFFFCFCFC), - onSurface = Color(0xFF232629), - surfaceVariant = Color(0xFFE5E9EC), - onSurfaceVariant = Color(0xFF4D4D4D), - surfaceContainer = Color(0xFFF2F3F4), - surfaceContainerHigh = Color(0xFFEAECEE), - surfaceContainerHighest = Color(0xFFE0E3E5), - surfaceContainerLow = Color(0xFFF7F8F9), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFBABEC2), - outlineVariant = Color(0xFFD9DCDF), - ) - - // ── Windows 11 (WinUI 3 / Mica) ─────────────────────────────────────────── - - private fun windowsDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF202020), - onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF2B2B2B), - onSurface = Color(0xFFFFFFFF), - surfaceVariant = Color(0xFF323232), - onSurfaceVariant = Color(0xFFCCCCCC), - surfaceContainer = Color(0xFF272727), - surfaceContainerHigh = Color(0xFF323232), - surfaceContainerHighest = Color(0xFF3D3D3D), - surfaceContainerLow = Color(0xFF1F1F1F), - surfaceContainerLowest = Color(0xFF1A1A1A), - outline = Color(0xFF5E5E5E), - outlineVariant = Color(0xFF3A3A3A), - ) - - private fun windowsLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFF3F3F3), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFBFBFB), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFEDEDED), - onSurfaceVariant = Color(0xFF555555), - surfaceContainer = Color(0xFFF6F6F6), - surfaceContainerHigh = Color(0xFFEDEDED), - surfaceContainerHighest = Color(0xFFE5E5E5), - surfaceContainerLow = Color(0xFFFAFAFA), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD8D8D8), - ) - - // ── Generic (other Linux DEs / Unknown) ────────────────────────────────── - - private fun genericDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1E1E1E), - surface = Color(0xFF1E1E1E), - surfaceVariant = Color(0xFF2A2A2A), - ) - - private fun genericLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - ) - - /** - * Picks readable text color (white or black) on top of the given accent based on - * its perceived luminance (Rec. 709 weights). - */ - private fun onAccent(accent: Color): Color { - val luminance = 0.2126f * accent.red + 0.7152f * accent.green + 0.0722f * accent.blue - return if (luminance > 0.55f) Color.Black else Color.White - } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt index fbd1831404..e2935bec2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt @@ -25,67 +25,15 @@ import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp /** - * Per-OS Material3 [Shapes] tuned to match each platform's native rounding language. - * Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users - * see in their OS's first-party apps. - * - * - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners. - * - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline. - * - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita. - * - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding. + * Unified Amethyst brand [Shapes] — consistent rounding across all platforms. */ object PlatformShapes { - val current: Shapes by lazy { - when (PlatformInfo.current) { - Platform.MACOS -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(10.dp), - large = RoundedCornerShape(14.dp), - extraLarge = RoundedCornerShape(20.dp), - ) - } - - Platform.GNOME -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(9.dp), - medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(24.dp), - ) - } - - Platform.KDE -> { - Shapes( - extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(6.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(12.dp), - extraLarge = RoundedCornerShape(16.dp), - ) - } - - Platform.WINDOWS -> { - Shapes( - extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(8.dp), - extraLarge = RoundedCornerShape(12.dp), - ) - } - - Platform.LINUX_OTHER, Platform.UNKNOWN -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(10.dp), - large = RoundedCornerShape(14.dp), - extraLarge = RoundedCornerShape(20.dp), - ) - } - } - } + val current: Shapes = + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), + ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt index 739d0ee0a6..3b508d089c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt @@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.desktop.platform import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope +import com.vitorpamplona.amethyst.desktop.ui.theme.AmethystSpacing +import com.vitorpamplona.amethyst.desktop.ui.theme.LocalIsDarkTheme +import com.vitorpamplona.amethyst.desktop.ui.theme.LocalSpacing /** * Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent @@ -41,14 +45,18 @@ fun PlatformMaterialTheme( isDark: Boolean, content: @Composable () -> Unit, ) { - val accent = remember { PlatformAccent.systemAccent() } - val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) } - MaterialTheme( - colorScheme = colorScheme, - typography = PlatformTypography.current, - shapes = PlatformShapes.current, - content = content, - ) + val colorScheme = remember(isDark) { PlatformColorScheme.resolve(isDark) } + CompositionLocalProvider( + LocalSpacing provides AmethystSpacing(), + LocalIsDarkTheme provides isDark, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = PlatformTypography.current, + shapes = PlatformShapes.current, + content = content, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt index f11e028393..97e6195241 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt @@ -40,19 +40,7 @@ object PlatformTypography { val current: Typography by lazy { build(PlatformFonts.ui) } private fun build(family: FontFamily): Typography { - // Per-OS letter spacing offset applied to display/headline styles. - val tightening: TextUnit = - when (PlatformInfo.current) { - Platform.MACOS -> (-0.4).sp - - // SF Pro is set tight at large sizes - Platform.GNOME -> (-0.2).sp - - // Adwaita Sans is mildly tight - Platform.KDE, Platform.WINDOWS -> 0.sp - - else -> 0.sp - } + val tightening: TextUnit = (-0.3).sp fun ts( size: Int, @@ -68,9 +56,9 @@ object PlatformTypography { ) return Typography( - displayLarge = ts(57, 64, FontWeight.Normal, tightening), - displayMedium = ts(45, 52, FontWeight.Normal, tightening), - displaySmall = ts(36, 44, FontWeight.Normal, tightening), + displayLarge = ts(57, 64, FontWeight.Light, tightening), + displayMedium = ts(45, 52, FontWeight.Light, tightening), + displaySmall = ts(36, 44, FontWeight.Light, tightening), headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening), headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening), headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 5ca2760bf1..28a0a761a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -33,10 +34,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -148,14 +149,15 @@ private fun DraftCard( onClick: () -> Unit, onDelete: () -> Unit, ) { - Card( + OutlinedCard( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Row( modifier = Modifier.padding(16.dp).fillMaxWidth(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 0706f93fb6..2d5d569a22 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -32,12 +33,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -96,14 +97,15 @@ fun LongFormCard( val authorName = author.toBestDisplayName() val publishedAt = event.publishedAt() ?: event.createdAt - Card( + OutlinedCard( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Column(modifier = Modifier.padding(16.dp)) { // Title diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index cd6a13e876..a7c5742eac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -48,6 +49,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text @@ -1139,13 +1141,14 @@ private fun PublishedHighlightCard( val articleAddress = highlight.inPostAddress() val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source" - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Column(modifier = Modifier.padding(16.dp)) { // Quoted highlight text diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index d1e8cf9a74..629b5fa127 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -56,13 +56,13 @@ fun ColumnHeader( modifier = modifier .fillMaxWidth() - .height(40.dp) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) .pointerInput(Unit) { detectTapGestures( onDoubleTap = { onDoubleClick() }, ) - }.padding(horizontal = 8.dp), + }.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { if (hasBackStack) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 8ce5c543e5..96058bcf88 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon @@ -51,6 +52,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce import java.awt.Cursor @Composable @@ -80,14 +83,23 @@ fun DeckLayout( val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } deckState.setAvailableWidth(availableWidthDp) - // Auto-fit columns on first composition or when available width changes significantly - LaunchedEffect(availableWidthDp, columns.size) { - val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH - val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat() - val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp) - if (diff > 20f && columns.isNotEmpty()) { - deckState.fitColumnsToWidth(availableWidthDp) - } + // Auto-fit columns when available width changes (debounced to avoid per-frame + // recomposition during sidebar expand/collapse animation) + @OptIn(FlowPreview::class) + LaunchedEffect(Unit) { + snapshotFlow { availableWidthDp to columns.size } + .debounce(300) + .collect { (width, size) -> + val dividers = (size - 1) * DeckState.DIVIDER_WIDTH + val totalColumnWidth = + deckState.columns.value + .sumOf { it.width.toDouble() } + .toFloat() + val diff = kotlin.math.abs(totalColumnWidth + dividers - width) + if (diff > 20f && size > 0) { + deckState.fitColumnsToWidth(width) + } + } } Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 6cea5f0013..078e6b8a7f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -20,32 +20,88 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut 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 +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.IconButton +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition 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.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.collections.immutable.ImmutableList +private val EXPANDED_WIDTH = 240.dp +private val COLLAPSED_WIDTH = 56.dp + +/** + * Data class for a sidebar navigation item. + */ +private data class NavItem( + val type: DeckColumnType, + val label: String, + val icon: MaterialSymbol, +) + +private val NAV_ITEMS = + listOf( + NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home), + NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search), + NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail), + NavItem(DeckColumnType.Wallet, "Wallet", MaterialSymbols.AccountBalanceWallet), + NavItem(DeckColumnType.Bookmarks, "Bookmarks", MaterialSymbols.Bookmark), + NavItem(DeckColumnType.Settings, "Settings", MaterialSymbols.Settings), + ) + +@OptIn(ExperimentalComposeUiApi::class) @Composable fun DeckSidebar( activeNpub: String?, @@ -56,67 +112,415 @@ fun DeckSidebar( onRemoveAccount: (String) -> Unit, onAddColumn: () -> Unit, onOpenSettings: () -> Unit, + onNavigate: (DeckColumnType) -> Unit, + activeColumnType: DeckColumnType?, onShowImportFollowListDialog: () -> Unit = {}, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, torStatus: TorServiceStatus, modifier: Modifier = Modifier, ) { + var expanded by remember { mutableStateOf(!DesktopPreferences.sidebarCollapsed) } + + val animatedWidth by animateDpAsState( + targetValue = if (expanded) EXPANDED_WIDTH else COLLAPSED_WIDTH, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + + // Observe metadata to recompose when display names/pictures load + @Suppress("UNUSED_VARIABLE") + val metadataVersion by localCache?.metadataVersion?.collectAsState() + ?: remember { mutableStateOf(0L) } + + // Resolve user info for avatar header + val pubkeyHex = remember(activeNpub) { activeNpub?.let { decodePublicKeyAsHexOrNull(it) } } + val user = + remember(pubkeyHex, metadataVersion) { + pubkeyHex?.let { localCache?.getUserIfExists(it) } + } + val displayName = + remember(user, metadataVersion) { + user?.let { + val name = it.toBestDisplayName() + name.takeIf { n -> n != it.pubkeyDisplayHex() } + } + } + val avatarUrl = remember(user, metadataVersion) { user?.profilePicture() } + + // Custom feeds from repository + val feedRepo = LocalFeedRepository.current + val allFeeds by feedRepo.feeds.collectAsState() + Column( modifier = modifier - .width(56.dp) + .width(animatedWidth) .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainer) + .clipToBounds() + .background(MaterialTheme.colorScheme.surface) .padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, ) { - AccountSwitcherDropdown( + // -- Avatar + Account header -- + SidebarAccountHeader( activeNpub = activeNpub, allAccounts = allAccounts, localCache = localCache, + displayName = displayName, + avatarUrl = avatarUrl, + pubkeyHex = pubkeyHex, + expanded = expanded, onSwitchAccount = onSwitchAccount, onAddAccount = onAddAccount, onRemoveAccount = onRemoveAccount, ) - Spacer(Modifier.size(16.dp)) + Spacer(Modifier.height(8.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(8.dp)) - IconButton(onClick = onAddColumn) { - Icon( - MaterialSymbols.Add, - contentDescription = "Add Column", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // -- Nav items (scrollable) -- + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + ) { + NAV_ITEMS.forEach { item -> + val isActive = activeColumnType?.typeKey() == item.type.typeKey() + SidebarNavItem( + icon = item.icon, + label = item.label, + isActive = isActive, + expanded = expanded, + onClick = { onNavigate(item.type) }, + ) + } + + // -- Custom feeds section -- + if (allFeeds.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(8.dp)) + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = "FEEDS", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + allFeeds.forEach { feed -> + val isActive = + activeColumnType is DeckColumnType.CustomFeed && + activeColumnType.feedId == feed.id + SidebarFeedItem( + feed = feed, + isActive = isActive, + expanded = expanded, + onClick = { + onNavigate( + DeckColumnType.CustomFeed( + feedId = feed.id, + feedName = feed.name, + feedEmoji = feed.emoji, + ), + ) + }, + ) + } + } } - IconButton(onClick = onShowImportFollowListDialog) { - Icon( - MaterialSymbols.PersonAdd, - contentDescription = "Import Follow List", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + // -- Bottom section: indicators + collapse toggle -- + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, ) + Spacer(Modifier.width(4.dp)) + TorStatusIndicator(status = torStatus, onClick = onOpenSettings) } - Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(4.dp)) - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, + // Collapse toggle + SidebarNavItem( + icon = if (expanded) MaterialSymbols.AutoMirrored.KeyboardArrowLeft else MaterialSymbols.ChevronRight, + label = if (expanded) "Collapse" else "Expand", + isActive = false, + expanded = expanded, + onClick = { + expanded = !expanded + DesktopPreferences.sidebarCollapsed = !expanded + }, ) + } +} - Spacer(Modifier.size(4.dp)) - TorStatusIndicator(status = torStatus, onClick = onOpenSettings) +@Composable +private fun SidebarAccountHeader( + activeNpub: String?, + allAccounts: ImmutableList, + localCache: DesktopLocalCache?, + displayName: String?, + avatarUrl: String?, + pubkeyHex: String?, + expanded: Boolean, + onSwitchAccount: (String) -> Unit, + onAddAccount: () -> Unit, + onRemoveAccount: (String) -> Unit, +) { + if (!expanded) { + // Collapsed: just the account switcher dropdown (icon-only) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + AccountSwitcherDropdown( + activeNpub = activeNpub, + allAccounts = allAccounts, + localCache = localCache, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, + onRemoveAccount = onRemoveAccount, + ) + } + } else { + // Expanded: avatar + name + npub, tappable to open account switcher + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Avatar + if (pubkeyHex != null) { + UserAvatar( + userHex = pubkeyHex, + pictureUrl = avatarUrl, + size = 40.dp, + contentDescription = "Account avatar", + ) + } else { + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + MaterialSymbols.Person, + contentDescription = "Account", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } - Spacer(Modifier.size(4.dp)) + Spacer(Modifier.width(10.dp)) - IconButton(onClick = onOpenSettings) { - Icon( - MaterialSymbols.Settings, - contentDescription = "Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName ?: "Account", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (activeNpub != null) { + Text( + text = npubShortForSidebar(activeNpub), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + // Overlay the dropdown so clicking opens it + AccountSwitcherDropdown( + activeNpub = activeNpub, + allAccounts = allAccounts, + localCache = localCache, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, + onRemoveAccount = onRemoveAccount, + modifier = Modifier.matchParentSize(), ) } } } + +/** + * A single navigation item row with icon + optional label. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SidebarNavItem( + icon: MaterialSymbol, + label: String, + isActive: Boolean, + expanded: Boolean, + onClick: () -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + + val backgroundColor = + when { + isActive -> MaterialTheme.colorScheme.primaryContainer + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else -> MaterialTheme.colorScheme.surface + } + + val iconTint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val textColor = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = if (!expanded) label else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +/** + * A custom feed item in the sidebar. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SidebarFeedItem( + feed: FeedDefinition, + isActive: Boolean, + expanded: Boolean, + onClick: () -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + + val backgroundColor = + when { + isActive -> MaterialTheme.colorScheme.primaryContainer + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else -> MaterialTheme.colorScheme.surface + } + + val iconTint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val textColor = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (feed.emoji.isNotEmpty() && expanded) { + Text( + text = feed.emoji, + modifier = Modifier.size(24.dp), + style = MaterialTheme.typography.titleMedium, + ) + } else { + Icon( + MaterialSymbols.AutoMirrored.Feed, + contentDescription = if (!expanded) feed.name else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = feed.name.ifEmpty { "Feed" }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +private fun npubShortForSidebar(npub: String): String { + if (npub.length <= 20) return npub + return "${npub.take(10)}...${npub.takeLast(6)}" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index 8fbc168f55..7cae7fa2eb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.highlights +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,10 +35,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -175,13 +176,14 @@ private fun HighlightCard( highlight: HighlightData, onDelete: () -> Unit, ) { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Row( modifier = Modifier.padding(16.dp).fillMaxWidth(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 7b152971ec..0e0a9a4031 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.note +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -36,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -147,10 +149,15 @@ fun NoteCard( // the NoteActionsRow have their own clickables that consume the click before // it reaches the Card's handler, so tapping an action still fires only that // action. - val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + val outlineVariant = MaterialTheme.colorScheme.outlineVariant + val cardBorder = + remember(outlineVariant) { + BorderStroke(1.dp, outlineVariant) + } + val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface) + val cardShape = MaterialTheme.shapes.medium val cardBody: @Composable ColumnScope.() -> Unit = { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(16.dp)) { Column { Row( modifier = Modifier.fillMaxWidth(), @@ -302,18 +309,20 @@ fun NoteCard( } if (onClick != null) { - Card( + OutlinedCard( onClick = onClick, modifier = modifier.fillMaxWidth(), colors = cardColors, - elevation = cardElevation, + border = cardBorder, + shape = cardShape, content = cardBody, ) } else { - Card( + OutlinedCard( modifier = modifier.fillMaxWidth(), colors = cardColors, - elevation = cardElevation, + border = cardBorder, + shape = cardShape, content = cardBody, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt new file mode 100644 index 0000000000..edb375602d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt @@ -0,0 +1,53 @@ +/* + * 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.desktop.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class AmethystSpacing( + val xxs: Dp = 2.dp, + val xs: Dp = 4.dp, + val sm: Dp = 8.dp, + val md: Dp = 12.dp, + val lg: Dp = 16.dp, + val xl: Dp = 24.dp, + val xxl: Dp = 32.dp, + val cardPadding: Dp = 16.dp, + val cardGap: Dp = 8.dp, + val sidebarExpandedWidth: Dp = 240.dp, + val sidebarCollapsedWidth: Dp = 56.dp, + val columnHeaderHeight: Dp = 48.dp, +) + +val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() } + +val LocalIsDarkTheme = staticCompositionLocalOf { false } + +val MaterialTheme.spacing: AmethystSpacing + @Composable @ReadOnlyComposable + get() = LocalSpacing.current diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt new file mode 100644 index 0000000000..eb6858691a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.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.desktop.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent + +/** + * Adds a subtle background highlight on hover. The state read is deferred to + * the draw phase via [drawBehind], so hover enter/exit only invalidates draw — + * no recomposition of the host composable. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun Modifier.hoverHighlight(hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)): Modifier { + val color = remember { mutableStateOf(Color.Transparent) } + return this + .onPointerEvent(PointerEventType.Enter) { color.value = hoverColor } + .onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent } + .drawBehind { drawRect(color.value) } +} From eb114f0a58c44346a11878aa68eef141b02f0293 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 06:54:08 +0300 Subject: [PATCH 20/51] refactor(desktop): share MainSidebar across single-pane and deck modes - Rename DeckSidebar -> MainSidebar (it's not deck-specific anymore) - Hoist MainSidebar from DECK-only to shared Row in Main.kt - Remove inline 80dp NavigationRail from SinglePaneLayout - SinglePaneLayout now only renders content pane (sidebar is external) - onNavigate dispatches to singlePaneState.navigate() or deckState.addColumn() based on active layoutMode - Active item highlighting works in both modes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 149 +++++++----- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 2 +- .../desktop/ui/deck/SinglePaneLayout.kt | 213 +----------------- 3 files changed, 92 insertions(+), 272 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 294609c1d1..e37fd76e98 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -104,8 +104,8 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout -import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState +import com.vitorpamplona.amethyst.desktop.ui.deck.MainSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState @@ -1346,6 +1346,92 @@ fun MainContent( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize().weight(1f)) { + // Shared sidebar for both layout modes + if (!isImmersive) { + val allAccountsState by accountManager.allAccounts.collectAsState() + var showAddAccountDialog by remember { mutableStateOf(false) } + + // Derive active column type based on layout mode + val activeColumnType = + when (layoutMode) { + LayoutMode.DECK -> { + val deckColumns by deckState.columns.collectAsState() + val focusedIdx by deckState.focusedColumnIndex.collectAsState() + deckColumns.getOrNull(focusedIdx)?.type + } + LayoutMode.SINGLE_PANE -> { + val currentScreen by singlePaneState.currentScreen.collectAsState() + currentScreen + } + } + + MainSidebar( + activeNpub = accountManager.currentAccount()?.npub, + allAccounts = allAccountsState, + localCache = localCache, + onSwitchAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.switchAccount(npub) + } + }, + onAddAccount = { showAddAccountDialog = true }, + onRemoveAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.removeAccountFromStorage(npub) + } + }, + onAddColumn = onShowAppDrawer, + onOpenSettings = { + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(DeckColumnType.Settings) + } + } + }, + onNavigate = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } + } + }, + activeColumnType = activeColumnType, + onShowImportFollowListDialog = onShowImportFollowListDialog, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + torStatus = torStatus, + ) + + VerticalDivider() + + if (showAddAccountDialog) { + com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog( + accountManager = accountManager, + onDismiss = { showAddAccountDialog = false }, + onAccountAdded = { + showAddAccountDialog = false + scope.launch(Dispatchers.IO) { + accountManager.refreshAccountList() + } + }, + ) + } + } + when (layoutMode) { LayoutMode.SINGLE_PANE -> { val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState() @@ -1377,67 +1463,6 @@ fun MainContent( } LayoutMode.DECK -> { - if (!isImmersive) { - val allAccountsState by accountManager.allAccounts.collectAsState() - var showAddAccountDialog by remember { mutableStateOf(false) } - - val deckColumns by deckState.columns.collectAsState() - val focusedIdx by deckState.focusedColumnIndex.collectAsState() - val activeColumnType = deckColumns.getOrNull(focusedIdx)?.type - - DeckSidebar( - activeNpub = accountManager.currentAccount()?.npub, - allAccounts = allAccountsState, - localCache = localCache, - onSwitchAccount = { npub -> - scope.launch(Dispatchers.IO) { - accountManager.switchAccount(npub) - } - }, - onAddAccount = { showAddAccountDialog = true }, - onRemoveAccount = { npub -> - scope.launch(Dispatchers.IO) { - accountManager.removeAccountFromStorage(npub) - } - }, - onAddColumn = onShowAppDrawer, - onOpenSettings = { - if (deckState.hasColumnOfType(DeckColumnType.Settings)) { - deckState.focusExistingColumn(DeckColumnType.Settings) - } else { - deckState.addColumn(DeckColumnType.Settings) - } - }, - onNavigate = { type -> - if (deckState.hasColumnOfType(type)) { - deckState.focusExistingColumn(type) - } else { - deckState.addColumn(type) - } - }, - activeColumnType = activeColumnType, - onShowImportFollowListDialog = onShowImportFollowListDialog, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - torStatus = torStatus, - ) - - VerticalDivider() - - if (showAddAccountDialog) { - com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog( - accountManager = accountManager, - onDismiss = { showAddAccountDialog = false }, - onAccountAdded = { - showAddAccountDialog = false - scope.launch(Dispatchers.IO) { - accountManager.refreshAccountList() - } - }, - ) - } - } - DeckLayout( deckState = deckState, relayManager = relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 078e6b8a7f..0b1d11dfce 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -103,7 +103,7 @@ private val NAV_ITEMS = @OptIn(ExperimentalComposeUiApi::class) @Composable -fun DeckSidebar( +fun MainSidebar( activeNpub: String?, allAccounts: ImmutableList, localCache: DesktopLocalCache?, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 9e7fb7d7d9..922f69f934 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -21,60 +21,30 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher -import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback -import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown -import com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog -import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator -import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen -import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState -import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun SinglePaneLayout( @@ -107,185 +77,10 @@ fun SinglePaneLayout( val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() - val isImmersive by LocalIsImmersiveFullscreen.current - - Row(modifier = modifier.fillMaxSize()) { - if (!isImmersive) { - // Custom navigation rail with a scrollable items area so all pinned - // screens (Home, Reads, Notifications, ...) remain reachable when - // the window is short. Bottom status indicators stay anchored. - // - // We don't use Material3 `NavigationRail` directly here because its - // internal `Column` is not scrollable — when there are more pinned - // items than fit vertically, items at the bottom of the list (and - // the "More" button) get clipped on small windows. We replicate the - // rail's container styling via NavigationRailItemDefaults so item - // visuals are unchanged. - val railScrollState = rememberScrollState() - val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() - val torState = LocalTorState.current - val allAccountsState by accountManager.allAccounts.collectAsState() - val singlePaneScope = rememberCoroutineScope() - var showAddAccountDialog by remember { mutableStateOf(false) } - - Column( - modifier = - Modifier - .width(80.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainer), - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, - ) { - // macOS: push rail items below the traffic lights. - Spacer(Modifier.height(titleBarInsetTop)) - - // Scrollable region for pinned screens + "More" launcher. - // Takes all remaining space above the fixed bottom controls - // (weight(1f)) and scrolls vertically when items overflow. - Column( - modifier = - Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(railScrollState), - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - ) { - pinnedScreens.forEach { screenType -> - // Rename "Home" to "Feeds" in the nav rail - val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title() - NavigationRailItem( - selected = currentColumnType == screenType && navStack.isEmpty(), - onClick = { - singlePaneState.navigate(screenType) - navState.clear() - }, - icon = { - Icon( - screenType.icon(), - contentDescription = label, - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - label, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - colors = NavigationRailItemDefaults.colors(), - ) - } - - NavigationRailItem( - selected = false, - onClick = onShowImportFollowListDialog, - icon = { - Icon( - MaterialSymbols.PersonAdd, - contentDescription = "Import Follow List", - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - "Import", - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - ) - }, - colors = NavigationRailItemDefaults.colors(), - ) - - NavigationRailItem( - selected = false, - onClick = onOpenAppDrawer, - icon = { - Icon( - MaterialSymbols.Apps, - contentDescription = "App Drawer", - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - "More", - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - ) - }, - colors = NavigationRailItemDefaults.colors(), - ) - } - - // Fixed bottom controls — always visible regardless of how - // many items are in the scrollable region above. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, - ) { - // Relay health — shows elapsed time since last event (hidden when <30s) - RelayHealthIndicator( - lastEventReceivedAt = lastRelayEventAt, - modifier = Modifier.padding(bottom = 4.dp), - ) - - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - modifier = Modifier.padding(bottom = 4.dp), - ) - - TorStatusIndicator( - status = torState.status, - onClick = { - singlePaneState.navigate(DeckColumnType.Settings) - navState.clear() - }, - modifier = Modifier.padding(bottom = 4.dp), - ) - - AccountSwitcherDropdown( - activeNpub = accountManager.currentAccount()?.npub, - allAccounts = allAccountsState, - localCache = localCache, - onSwitchAccount = { npub -> - singlePaneScope.launch(Dispatchers.IO) { - accountManager.switchAccount(npub) - } - }, - onAddAccount = { showAddAccountDialog = true }, - onRemoveAccount = { npub -> - singlePaneScope.launch(Dispatchers.IO) { - accountManager.removeAccountFromStorage(npub) - } - }, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - } - - if (showAddAccountDialog) { - AddAccountDialog( - accountManager = accountManager, - onDismiss = { showAddAccountDialog = false }, - onAccountAdded = { - showAddAccountDialog = false - singlePaneScope.launch(Dispatchers.IO) { - accountManager.refreshAccountList() - } - }, - ) - } - } - - if (!isImmersive) { - VerticalDivider() - } - - Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes). + // SinglePaneLayout only renders the content pane. + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { // Offline banner — shows when no remote relays connected val connectedRelays by relayManager.connectedRelays.collectAsState() val localRelay = LocalLocalRelayStore.current From c21b85c83e552865bbc03328681bd997e36bbd8e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 09:22:11 +0300 Subject: [PATCH 21/51] feat(desktop): add search spotlight overlay with Cmd+F shortcut - SearchSpotlight: global overlay with scrim, auto-focused input, recent/saved searches, hashtag quick-jump, and "Open full search" link - SearchPill: compact reusable pill component with hover highlight and shortcut hint - Cmd+F keyboard shortcut wired in MenuBar to open/close spotlight - SearchPill added to FeedTabsHeader (Home/Feeds column headers) - Result selection dispatches to deckState (deck mode) or singlePaneState (single-pane) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 72 +++++ .../amethyst/desktop/ui/FeedScreen.kt | 21 +- .../amethyst/desktop/ui/search/SearchPill.kt | 79 +++++ .../desktop/ui/search/SearchSpotlight.kt | 305 ++++++++++++++++++ 4 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSpotlight.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e37fd76e98..449cc79c2b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -263,6 +263,7 @@ fun main() { var showAppDrawer by remember { mutableStateOf(false) } var showAddColumnDialog by remember { mutableStateOf(false) } var showImportFollowListDialog by remember { mutableStateOf(false) } + var showSearchSpotlight by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -443,6 +444,16 @@ fun main() { ) } Menu("View") { + Item( + "Search", + shortcut = + if (isMacOS) { + KeyShortcut(Key.F, meta = true) + } else { + KeyShortcut(Key.F, ctrl = true) + }, + onClick = { showSearchSpotlight = true }, + ) Item( "App Drawer", shortcut = @@ -618,6 +629,9 @@ fun main() { accountManager = accountManager, showComposeDialog = showComposeDialog, showAppDrawer = showAppDrawer, + showSearchSpotlight = showSearchSpotlight, + onShowSearchSpotlight = { showSearchSpotlight = true }, + onDismissSearchSpotlight = { showSearchSpotlight = false }, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -655,6 +669,9 @@ fun App( accountManager: AccountManager, showComposeDialog: Boolean, showAppDrawer: Boolean, + showSearchSpotlight: Boolean, + onShowSearchSpotlight: () -> Unit, + onDismissSearchSpotlight: () -> Unit, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, @@ -1059,6 +1076,61 @@ fun App( ) } + // Search Spotlight overlay + if (showSearchSpotlight) { + com.vitorpamplona.amethyst.desktop.ui.search.SearchSpotlight( + onSelectProfile = { pubkey -> + onDismissSearchSpotlight() + when (layoutMode) { + LayoutMode.DECK -> { + deckState.addColumn(DeckColumnType.Profile(pubkey)) + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(DeckColumnType.MyProfile) + } + } + }, + onSelectNote = { noteId -> + onDismissSearchSpotlight() + when (layoutMode) { + LayoutMode.DECK -> { + deckState.addColumn(DeckColumnType.Thread(noteId)) + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(DeckColumnType.HomeFeed) + } + } + }, + onSelectHashtag = { tag -> + onDismissSearchSpotlight() + when (layoutMode) { + LayoutMode.DECK -> { + deckState.addColumn(DeckColumnType.Hashtag(tag)) + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(DeckColumnType.Search) + } + } + }, + onOpenFullSearch = { queryText -> + onDismissSearchSpotlight() + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(DeckColumnType.Search)) { + deckState.focusExistingColumn(DeckColumnType.Search) + } else { + deckState.addColumn(DeckColumnType.Search) + } + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(DeckColumnType.Search) + } + } + }, + onDismiss = { onDismissSearchSpotlight() }, + ) + } + // App Drawer overlay if (showAppDrawer) { val openColumns by deckState.columns.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 4bc55e7cee..4973b8b34b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog @@ -297,6 +298,7 @@ fun FeedScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, onNavigateToRelays: () -> Unit = {}, + onSearchClick: () -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -580,6 +582,7 @@ fun FeedScreen( }, onOpenFeedsDrawer = onOpenFeedsDrawer, onCompose = onCompose, + onSearchClick = onSearchClick, ) Spacer(Modifier.height(8.dp)) @@ -751,6 +754,7 @@ private fun FeedTabsHeader( onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {}, onOpenFeedsDrawer: () -> Unit, onCompose: () -> Unit, + onSearchClick: () -> Unit = {}, ) { val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() @@ -809,13 +813,18 @@ private fun FeedTabsHeader( ) } - // Compose button - IconButton(onClick = onCompose) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Compose", - modifier = Modifier.size(20.dp), + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + com.vitorpamplona.amethyst.desktop.ui.search.SearchPill( + onClick = onSearchClick, + modifier = Modifier.width(180.dp), ) + IconButton(onClick = onCompose) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Compose", + modifier = Modifier.size(20.dp), + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt new file mode 100644 index 0000000000..7c2a5ee463 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -0,0 +1,79 @@ +/* + * 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.desktop.ui.search + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.desktop.platform.PlatformInfo +import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight + +@Composable +fun SearchPill( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shortcutHint = if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F" + Surface( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.height(36.dp).hoverHighlight(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = "Search", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Search...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + Text( + shortcutHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSpotlight.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSpotlight.kt new file mode 100644 index 0000000000..ade27c48f7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSpotlight.kt @@ -0,0 +1,305 @@ +/* + * 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.desktop.ui.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore +import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight + +@Composable +fun SearchSpotlight( + onSelectProfile: (String) -> Unit, + onSelectNote: (String) -> Unit, + onSelectHashtag: (String) -> Unit, + onOpenFullSearch: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + + val history by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + + val hasQuery = textFieldValue.text.isNotBlank() + + Dialog(onDismissRequest = onDismiss) { + // Full-screen scrim + centered card + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + onDismiss() + }, + contentAlignment = Alignment.TopCenter, + ) { + // Search card — offset 15% from top + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + modifier = + Modifier + .padding(top = 80.dp) + .widthIn(max = 600.dp) + .fillMaxWidth(0.9f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + // consume clicks so they don't dismiss + }, + ) { + Column( + modifier = + Modifier + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + onDismiss() + true + } else { + false + } + }, + ) { + // Search input + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + BasicTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box { + if (textFieldValue.text.isEmpty()) { + Text( + "Search notes, profiles, hashtags...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + innerTextField() + } + }, + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + // Results or history + LazyColumn( + modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(), + ) { + if (!hasQuery) { + // Recent searches + if (history.isNotEmpty()) { + item { + Text( + "Recent", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp, 12.dp, 16.dp, 4.dp), + ) + } + items(history.take(5)) { query -> + val text = QuerySerializer.serialize(query) + SpotlightRow( + icon = { Icon(MaterialSymbols.History, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + text = text, + onClick = { + textFieldValue = TextFieldValue(text, TextRange(text.length)) + }, + ) + } + } + + // Saved searches + if (savedSearches.isNotEmpty()) { + item { + Text( + "Saved", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp, 12.dp, 16.dp, 4.dp), + ) + } + items(savedSearches.take(5)) { saved -> + SpotlightRow( + icon = { Icon(MaterialSymbols.Bookmark, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) }, + text = saved.label, + onClick = { onOpenFullSearch(QuerySerializer.serialize(saved.query)) }, + ) + } + } + + if (history.isEmpty() && savedSearches.isEmpty()) { + item { + Text( + "Type to search notes, profiles, and hashtags", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } else { + // Placeholder for live results — Phase 2 will wire AdvancedSearchBarState + item { + SpotlightRow( + icon = { Icon(MaterialSymbols.Search, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + text = "Search for \"${textFieldValue.text}\"", + onClick = { onOpenFullSearch(textFieldValue.text) }, + ) + } + + item { + SpotlightRow( + icon = { Icon(MaterialSymbols.Tag, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + text = "#${textFieldValue.text.removePrefix("#")}", + onClick = { onSelectHashtag(textFieldValue.text.removePrefix("#")) }, + ) + } + + // "Open full search" at bottom + item { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + SpotlightRow( + icon = { + Icon( + MaterialSymbols.AutoMirrored.OpenInNew, + null, + Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + text = "Open full search", + textColor = MaterialTheme.colorScheme.primary, + onClick = { onOpenFullSearch(textFieldValue.text) }, + ) + } + } + } + } + } + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +private fun SpotlightRow( + icon: @Composable () -> Unit, + text: String, + textColor: Color = MaterialTheme.colorScheme.onSurface, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .hoverHighlight() + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + icon() + Spacer(Modifier.width(12.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = textColor, + maxLines = 1, + ) + } +} From 55e7fcb90390d8ea6c05a0b3526102522ffd071d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 09:34:42 +0300 Subject: [PATCH 22/51] fix(desktop): feed header card design, inline search, compact tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedTabsHeader wrapped in Surface card (border + rounded corners) - Feed tabs compact with spacedBy(4dp), removed "+ More" (in sidebar) - SearchPill centered with weight(1f), expands inline to BasicTextField with DropdownMenu showing recent + saved searches - Removed Dialog-based SearchSpotlight (wrong UX — created separate AWT window) - Cmd+F now navigates to Search column via navigateToScreen callback - All feed tab types (Following/Global/Custom) properly handled in onClick Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 64 +---- .../amethyst/desktop/ui/FeedScreen.kt | 71 ++--- .../amethyst/desktop/ui/search/SearchPill.kt | 271 ++++++++++++++++-- 3 files changed, 279 insertions(+), 127 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 449cc79c2b..ea3c300d76 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -263,7 +263,6 @@ fun main() { var showAppDrawer by remember { mutableStateOf(false) } var showAddColumnDialog by remember { mutableStateOf(false) } var showImportFollowListDialog by remember { mutableStateOf(false) } - var showSearchSpotlight by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -452,7 +451,7 @@ fun main() { } else { KeyShortcut(Key.F, ctrl = true) }, - onClick = { showSearchSpotlight = true }, + onClick = { navigateToScreen?.invoke(DeckColumnType.Search) }, ) Item( "App Drawer", @@ -629,9 +628,6 @@ fun main() { accountManager = accountManager, showComposeDialog = showComposeDialog, showAppDrawer = showAppDrawer, - showSearchSpotlight = showSearchSpotlight, - onShowSearchSpotlight = { showSearchSpotlight = true }, - onDismissSearchSpotlight = { showSearchSpotlight = false }, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -669,9 +665,6 @@ fun App( accountManager: AccountManager, showComposeDialog: Boolean, showAppDrawer: Boolean, - showSearchSpotlight: Boolean, - onShowSearchSpotlight: () -> Unit, - onDismissSearchSpotlight: () -> Unit, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, @@ -1076,61 +1069,6 @@ fun App( ) } - // Search Spotlight overlay - if (showSearchSpotlight) { - com.vitorpamplona.amethyst.desktop.ui.search.SearchSpotlight( - onSelectProfile = { pubkey -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - deckState.addColumn(DeckColumnType.Profile(pubkey)) - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.MyProfile) - } - } - }, - onSelectNote = { noteId -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - deckState.addColumn(DeckColumnType.Thread(noteId)) - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.HomeFeed) - } - } - }, - onSelectHashtag = { tag -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - deckState.addColumn(DeckColumnType.Hashtag(tag)) - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.Search) - } - } - }, - onOpenFullSearch = { queryText -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - if (deckState.hasColumnOfType(DeckColumnType.Search)) { - deckState.focusExistingColumn(DeckColumnType.Search) - } else { - deckState.addColumn(DeckColumnType.Search) - } - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.Search) - } - } - }, - onDismiss = { onDismissSearchSpotlight() }, - ) - } - // App Drawer overlay if (showAppDrawer) { val openColumns by deckState.columns.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 4973b8b34b..f6463b4b06 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.Arrangement @@ -33,7 +34,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog @@ -760,69 +760,60 @@ private fun FeedTabsHeader( val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() val sidePadding = LocalReadingSidePadding.current - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = sidePadding + 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding + 8.dp, vertical = 8.dp), ) { - // Pinned feed tabs - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Compact feed tabs pinnedFeeds.forEach { feed -> val isSelected = when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> feedMode == FeedMode.FOLLOWING - } - - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> feedMode == FeedMode.GLOBAL - } - - else -> { - activeFeedId == feed.id - } + else -> activeFeedId == feed.id } FilterChip( selected = isSelected, onClick = { when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> onFeedModeChange(FeedMode.FOLLOWING) - } - - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> onFeedModeChange(FeedMode.GLOBAL) - } - - else -> { - onNavigateToFeed(feed) - } + else -> onNavigateToFeed(feed) } }, - label = { Text("${feed.emoji} ${feed.name}") }, + label = { + Text( + "${feed.emoji} ${feed.name}", + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + }, ) } - // "Show More +" button - FilterChip( - selected = false, - onClick = onOpenFeedsDrawer, - label = { Text("+ More") }, - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + // Search pill — takes remaining center space com.vitorpamplona.amethyst.desktop.ui.search.SearchPill( onClick = onSearchClick, - modifier = Modifier.width(180.dp), + modifier = Modifier.weight(1f), ) - IconButton(onClick = onCompose) { + + // Compose button + IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) { Icon( MaterialSymbols.Edit, contentDescription = "Compose", - modifier = Modifier.size(20.dp), + modifier = Modifier.size(18.dp), ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt index 7c2a5ee463..fab04eb156 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -20,60 +20,283 @@ */ package com.vitorpamplona.amethyst.desktop.ui.search +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue 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.search.QuerySerializer +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight +/** + * Search pill that expands inline into a text input with history dropdown. + * Collapsed: clickable pill with search icon + shortcut hint. + * Expanded: BasicTextField in same pill shape + dropdown below with recent/saved searches. + */ @Composable fun SearchPill( onClick: () -> Unit, modifier: Modifier = Modifier, ) { val shortcutHint = if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F" + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var expanded by remember { mutableStateOf(false) } + var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + + val history by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + Surface( - onClick = onClick, shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.height(36.dp).hoverHighlight(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), - ) { - Icon( - MaterialSymbols.Search, - contentDescription = "Search", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Search...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.weight(1f)) - Spacer(Modifier.width(8.dp)) - Text( - shortcutHint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) + Box { + if (expanded) { + // Expanded: inline text input + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { state -> + if (!state.isFocused && expanded) { + expanded = false + textFieldValue = TextFieldValue("") + } + }.onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + expanded = false + textFieldValue = TextFieldValue("") + focusManager.clearFocus() + true + } else { + false + } + }, + textStyle = + MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (textFieldValue.text.isEmpty()) { + Text( + "Search notes, profiles...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + innerTextField() + } + }, + ) + Spacer(Modifier.width(8.dp)) + Text( + shortcutHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + + // Dropdown with history + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + textFieldValue = TextFieldValue("") + }, + ) { + if (history.isNotEmpty()) { + Text( + "Recent", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + history.take(5).forEach { query -> + val text = QuerySerializer.serialize(query) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { + textFieldValue = TextFieldValue(text, TextRange(text.length)) + onClick() + }.hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.History, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + if (savedSearches.isNotEmpty()) { + if (history.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + Text( + "Saved", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + savedSearches.take(5).forEach { saved -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() } + .hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.Bookmark, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + if (history.isEmpty() && savedSearches.isEmpty()) { + Text( + "Type to search", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() } + .hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.AutoMirrored.OpenInNew, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Open full search", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } else { + // Collapsed: clickable pill + Surface( + onClick = { + expanded = true + }, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.matchParentSize(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = "Search", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Search...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + Text( + shortcutHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + } + } + } + + if (expanded) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() } } } From c69c4462b9ec0e9b1a0846a8aba9f4aa41c20dcf Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 09:38:48 +0300 Subject: [PATCH 23/51] fix(desktop): search pill visibility, sidebar + Add Feed button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchPill: fix collapsed state not rendering — removed nested Box/Surface/matchParentSize, replaced with simple if/else on expanded state within single Surface+Row - Sidebar: add "+ Add Feed" item below custom feeds in FEEDS section - Dropdown menu moves outside Surface to render properly Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 9 + .../amethyst/desktop/ui/search/SearchPill.kt | 345 ++++++++---------- 2 files changed, 152 insertions(+), 202 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 0b1d11dfce..8ba2841162 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -231,6 +231,15 @@ fun MainSidebar( }, ) } + + // "+ Add Feed" button + SidebarNavItem( + icon = MaterialSymbols.Add, + label = "Add Feed", + isActive = false, + expanded = expanded, + onClick = onAddColumn, + ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt index fab04eb156..7692cfa1c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -64,11 +63,6 @@ import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight -/** - * Search pill that expands inline into a text input with history dropdown. - * Collapsed: clickable pill with search icon + shortcut hint. - * Expanded: BasicTextField in same pill shape + dropdown below with recent/saved searches. - */ @Composable fun SearchPill( onClick: () -> Unit, @@ -84,214 +78,161 @@ fun SearchPill( val savedSearches by SearchHistoryStore.savedSearches.collectAsState() Surface( + onClick = { expanded = true }, shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.height(36.dp).hoverHighlight(), ) { - Box { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = "Search", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + if (expanded) { - // Expanded: inline text input + BasicTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + expanded = false + textFieldValue = TextFieldValue("") + focusManager.clearFocus() + true + } else { + false + } + }, + textStyle = + MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (textFieldValue.text.isEmpty()) { + Text( + "Search notes, profiles...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + innerTextField() + } + }, + ) + } else { + Text( + "Search...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } + + Spacer(Modifier.width(8.dp)) + Text( + shortcutHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + + // Dropdown below the pill when expanded + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + textFieldValue = TextFieldValue("") + }, + ) { + if (history.isNotEmpty()) { + Text( + "Recent", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + history.take(5).forEach { query -> + val text = QuerySerializer.serialize(query) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { + textFieldValue = TextFieldValue(text, TextRange(text.length)) + }.hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), ) { - Icon( - MaterialSymbols.Search, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Icon(MaterialSymbols.History, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.width(8.dp)) - BasicTextField( - value = textFieldValue, - onValueChange = { textFieldValue = it }, - modifier = - Modifier - .weight(1f) - .focusRequester(focusRequester) - .onFocusChanged { state -> - if (!state.isFocused && expanded) { - expanded = false - textFieldValue = TextFieldValue("") - } - }.onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { - expanded = false - textFieldValue = TextFieldValue("") - focusManager.clearFocus() - true - } else { - false - } - }, - textStyle = - MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = true, - decorationBox = { innerTextField -> - Box(contentAlignment = Alignment.CenterStart) { - if (textFieldValue.text.isEmpty()) { - Text( - "Search notes, profiles...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - innerTextField() - } - }, - ) - Spacer(Modifier.width(8.dp)) - Text( - shortcutHint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - - // Dropdown with history - DropdownMenu( - expanded = expanded, - onDismissRequest = { - expanded = false - textFieldValue = TextFieldValue("") - }, - ) { - if (history.isNotEmpty()) { - Text( - "Recent", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - history.take(5).forEach { query -> - val text = QuerySerializer.serialize(query) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { - textFieldValue = TextFieldValue(text, TextRange(text.length)) - onClick() - }.hoverHighlight() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon( - MaterialSymbols.History, - null, - Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) - } - } - } - - if (savedSearches.isNotEmpty()) { - if (history.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - } - Text( - "Saved", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - savedSearches.take(5).forEach { saved -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { onClick() } - .hoverHighlight() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon( - MaterialSymbols.Bookmark, - null, - Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(8.dp)) - Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) - } - } - } - - if (history.isEmpty() && savedSearches.isEmpty()) { - Text( - "Type to search", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(12.dp), - ) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { onClick() } - .hoverHighlight() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon( - MaterialSymbols.AutoMirrored.OpenInNew, - null, - Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Open full search", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } else { - // Collapsed: clickable pill - Surface( - onClick = { - expanded = true - }, - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.matchParentSize(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), - ) { - Icon( - MaterialSymbols.Search, - contentDescription = "Search", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Search...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.weight(1f)) - Spacer(Modifier.width(8.dp)) - Text( - shortcutHint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } + Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) } } } + + if (savedSearches.isNotEmpty()) { + if (history.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + Text( + "Saved", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + savedSearches.take(5).forEach { saved -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() } + .hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon(MaterialSymbols.Bookmark, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + if (history.isEmpty() && savedSearches.isEmpty()) { + Text( + "Type to search", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() } + .hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon(MaterialSymbols.AutoMirrored.OpenInNew, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } } if (expanded) { From ec8fbd39804b5c026c452e5b44760937ec80a05a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 09:49:54 +0300 Subject: [PATCH 24/51] feat(desktop): full-width search expansion with Cmd+F via CompositionLocal - FeedTabsHeader expands to full-width search card when active - Feed tabs hidden, search input fills entire header width - History section (recent + saved) shown below input - "Open full search" link at bottom - Scrim overlay dims feed content when search is active - Cmd+F toggles search via LocalFeedSearchActive CompositionLocal - MutableState provided at Window level - FeedScreen reads it directly, no param threading needed - SearchPill simplified back to clickable-only (no inline expansion) - Escape or click scrim dismisses search Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 73 ++-- .../amethyst/desktop/ui/FeedScreen.kt | 333 +++++++++++++++--- .../amethyst/desktop/ui/search/SearchPill.kt | 178 +--------- .../desktop/ui/theme/AmethystSpacing.kt | 5 + 4 files changed, 342 insertions(+), 247 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index ea3c300d76..6e5e2c6a3e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -263,6 +263,7 @@ fun main() { var showAppDrawer by remember { mutableStateOf(false) } var showAddColumnDialog by remember { mutableStateOf(false) } var showImportFollowListDialog by remember { mutableStateOf(false) } + val feedSearchActiveState = remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -451,7 +452,7 @@ fun main() { } else { KeyShortcut(Key.F, ctrl = true) }, - onClick = { navigateToScreen?.invoke(DeckColumnType.Search) }, + onClick = { feedSearchActiveState.value = !feedSearchActiveState.value }, ) Item( "App Drawer", @@ -617,39 +618,43 @@ fun main() { LocalIsImmersiveFullscreen provides immersiveFullscreenState, ) { key(appRestartKey) { - App( - layoutMode = layoutMode, - onLayoutModeChange = { newMode -> - layoutMode = newMode - DesktopPreferences.layoutMode = newMode.name - }, - deckState = deckState, - workspaceManager = workspaceManager, - accountManager = accountManager, - showComposeDialog = showComposeDialog, - showAppDrawer = showAppDrawer, - onShowComposeDialog = { showComposeDialog = true }, - onShowReplyDialog = { event -> - replyToNote = event - showComposeDialog = true - }, - onDismissComposeDialog = { - showComposeDialog = false - replyToNote = null - }, - onDismissAppDrawer = { showAppDrawer = false }, - onShowAppDrawer = { showAppDrawer = true }, - replyToNote = replyToNote, - showImportFollowListDialog = showImportFollowListDialog, - onShowImportFollowListDialog = { showImportFollowListDialog = true }, - onDismissImportFollowListDialog = { showImportFollowListDialog = false }, - onRestartApp = { appRestartKey++ }, - torManager = torManager, - torTypeFlow = torTypeFlow, - externalPortFlow = externalPortFlow, - initialTorSettings = torSettings, - onNavigateToScreen = { navigateToScreen = it }, - ) + CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive provides feedSearchActiveState, + ) { + App( + layoutMode = layoutMode, + onLayoutModeChange = { newMode -> + layoutMode = newMode + DesktopPreferences.layoutMode = newMode.name + }, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = showComposeDialog, + showAppDrawer = showAppDrawer, + onShowComposeDialog = { showComposeDialog = true }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + onDismissComposeDialog = { + showComposeDialog = false + replyToNote = null + }, + onDismissAppDrawer = { showAppDrawer = false }, + onShowAppDrawer = { showAppDrawer = true }, + replyToNote = replyToNote, + showImportFollowListDialog = showImportFollowListDialog, + onShowImportFollowListDialog = { showImportFollowListDialog = true }, + onDismissImportFollowListDialog = { showImportFollowListDialog = false }, + onRestartApp = { appRestartKey++ }, + torManager = torManager, + torTypeFlow = torTypeFlow, + externalPortFlow = externalPortFlow, + initialTorSettings = torSettings, + onNavigateToScreen = { navigateToScreen = it }, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index f6463b4b06..af5436587e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,10 +37,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -54,7 +60,18 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -66,12 +83,14 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders @@ -300,6 +319,9 @@ fun FeedScreen( onNavigateToRelays: () -> Unit = {}, onSearchClick: () -> Unit = {}, ) { + val feedSearchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current + var searchActive by feedSearchActiveState + val onSearchActiveChange: (Boolean) -> Unit = { searchActive = it } val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() val followedUsers by localCache.followedUsers.collectAsState() @@ -560,10 +582,12 @@ fun FeedScreen( Box(modifier = Modifier.fillMaxSize()) { ReadingColumn { - // Header: pinned feed tabs + "Show More +" + // Header: pinned feed tabs + search pill (or expanded search card) FeedTabsHeader( feedMode = feedMode, activeFeedId = activeFeedId, + searchExpanded = searchActive, + onSearchExpandedChange = onSearchActiveChange, onFeedModeChange = { mode -> feedMode = mode activeFeedId = null @@ -724,6 +748,22 @@ fun FeedScreen( ) } + // Search scrim — dims feed content when search is expanded + if (searchActive) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + onSearchActiveChange(false) + }, + ) + } + // Lightbox overlay lightboxState?.let { state -> LightboxOverlay( @@ -750,6 +790,8 @@ fun FeedScreen( private fun FeedTabsHeader( feedMode: FeedMode, activeFeedId: String? = null, + searchExpanded: Boolean = false, + onSearchExpandedChange: (Boolean) -> Unit = {}, onFeedModeChange: (FeedMode) -> Unit, onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {}, onOpenFeedsDrawer: () -> Unit, @@ -764,58 +806,265 @@ private fun FeedTabsHeader( shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding + 8.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = sidePadding + 8.dp, vertical = 8.dp) + .zIndex(if (searchExpanded) 10f else 0f), ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - // Compact feed tabs - pinnedFeeds.forEach { feed -> - val isSelected = - when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> - feedMode == FeedMode.FOLLOWING - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> - feedMode == FeedMode.GLOBAL - else -> activeFeedId == feed.id + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (!searchExpanded) { + // Compact feed tabs + pinnedFeeds.forEach { feed -> + val isSelected = + when (feed.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> + feedMode == FeedMode.FOLLOWING + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> + feedMode == FeedMode.GLOBAL + else -> activeFeedId == feed.id + } + FilterChip( + selected = isSelected, + onClick = { + when (feed.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> + onFeedModeChange(FeedMode.FOLLOWING) + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> + onFeedModeChange(FeedMode.GLOBAL) + else -> onNavigateToFeed(feed) + } + }, + label = { + Text( + "${feed.emoji} ${feed.name}", + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + }, + ) } - FilterChip( - selected = isSelected, - onClick = { - when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> - onFeedModeChange(FeedMode.FOLLOWING) - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> - onFeedModeChange(FeedMode.GLOBAL) - else -> onNavigateToFeed(feed) + } + + // Search pill / expanded input — takes remaining center space + if (searchExpanded) { + SearchInput( + onDismiss = { onSearchExpandedChange(false) }, + onOpenFullSearch = onSearchClick, + modifier = Modifier.weight(1f), + ) + } else { + com.vitorpamplona.amethyst.desktop.ui.search.SearchPill( + onClick = { onSearchExpandedChange(true) }, + modifier = Modifier.weight(1f), + ) + } + + if (!searchExpanded) { + // Compose button + IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Compose", + modifier = Modifier.size(18.dp), + ) + } + } + } + + // Expanded search: history section below the input + if (searchExpanded) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SearchHistorySection( + onOpenFullSearch = { + onSearchExpandedChange(false) + onSearchClick() + }, + ) + } + } + } +} + +@Composable +private fun SearchInput( + onDismiss: () -> Unit, + onOpenFullSearch: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Escape -> { + onDismiss() + focusManager.clearFocus() + true + } + Key.Enter -> { + onOpenFullSearch() + true + } + else -> false + } + } else { + false } }, - label = { + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (textFieldValue.text.isEmpty()) { Text( - "${feed.emoji} ${feed.name}", - maxLines = 1, - style = MaterialTheme.typography.labelMedium, + "Search notes, profiles, hashtags...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) - }, - ) - } + } + innerTextField() + } + }, + ) + Spacer(Modifier.width(8.dp)) + Text( + if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } - // Search pill — takes remaining center space - com.vitorpamplona.amethyst.desktop.ui.search.SearchPill( - onClick = onSearchClick, - modifier = Modifier.weight(1f), + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +private fun SearchHistorySection(onOpenFullSearch: () -> Unit) { + val history by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + if (history.isNotEmpty()) { + Text( + "Recent", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), ) + history.take(5).forEach { query -> + val text = + com.vitorpamplona.amethyst.commons.search.QuerySerializer + .serialize(query) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onOpenFullSearch() } + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.History, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(10.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } - // Compose button - IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Compose", - modifier = Modifier.size(18.dp), + if (savedSearches.isNotEmpty()) { + if (history.isNotEmpty()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } + Text( + "Saved", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + savedSearches.take(5).forEach { saved -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onOpenFullSearch() } + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.Bookmark, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(10.dp)) + Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onOpenFullSearch() } + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.AutoMirrored.OpenInNew, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(10.dp)) + Text( + "Open full search", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt index 7692cfa1c9..24c7c0b2a9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -20,46 +20,22 @@ */ package com.vitorpamplona.amethyst.desktop.ui.search -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue 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.search.QuerySerializer -import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight @@ -69,16 +45,8 @@ fun SearchPill( modifier: Modifier = Modifier, ) { val shortcutHint = if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F" - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - var expanded by remember { mutableStateOf(false) } - var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } - - val history by SearchHistoryStore.history.collectAsState() - val savedSearches by SearchHistoryStore.savedSearches.collectAsState() - Surface( - onClick = { expanded = true }, + onClick = onClick, shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.height(36.dp).hoverHighlight(), @@ -94,52 +62,12 @@ fun SearchPill( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.width(8.dp)) - - if (expanded) { - BasicTextField( - value = textFieldValue, - onValueChange = { textFieldValue = it }, - modifier = - Modifier - .weight(1f) - .focusRequester(focusRequester) - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { - expanded = false - textFieldValue = TextFieldValue("") - focusManager.clearFocus() - true - } else { - false - } - }, - textStyle = - MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = true, - decorationBox = { innerTextField -> - Box(contentAlignment = Alignment.CenterStart) { - if (textFieldValue.text.isEmpty()) { - Text( - "Search notes, profiles...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - innerTextField() - } - }, - ) - } else { - Text( - "Search...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - } - + Text( + "Search...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) Spacer(Modifier.width(8.dp)) Text( shortcutHint, @@ -148,96 +76,4 @@ fun SearchPill( ) } } - - // Dropdown below the pill when expanded - DropdownMenu( - expanded = expanded, - onDismissRequest = { - expanded = false - textFieldValue = TextFieldValue("") - }, - ) { - if (history.isNotEmpty()) { - Text( - "Recent", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - history.take(5).forEach { query -> - val text = QuerySerializer.serialize(query) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { - textFieldValue = TextFieldValue(text, TextRange(text.length)) - }.hoverHighlight() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon(MaterialSymbols.History, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.width(8.dp)) - Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) - } - } - } - - if (savedSearches.isNotEmpty()) { - if (history.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - } - Text( - "Saved", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) - savedSearches.take(5).forEach { saved -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { onClick() } - .hoverHighlight() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon(MaterialSymbols.Bookmark, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(8.dp)) - Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) - } - } - } - - if (history.isEmpty() && savedSearches.isEmpty()) { - Text( - "Type to search", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(12.dp), - ) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { onClick() } - .hoverHighlight() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon(MaterialSymbols.AutoMirrored.OpenInNew, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(8.dp)) - Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) - } - } - - if (expanded) { - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt index edb375602d..4e52e11e32 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt @@ -24,6 +24,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -48,6 +50,9 @@ val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() } val LocalIsDarkTheme = staticCompositionLocalOf { false } +/** Mutable state for Cmd+F feed search. Provided at Main.kt level. */ +val LocalFeedSearchActive = compositionLocalOf { mutableStateOf(false) } + val MaterialTheme.spacing: AmethystSpacing @Composable @ReadOnlyComposable get() = LocalSpacing.current From c8e9a4fa6a0b42f3e13256542985c6bee1c1f0c4 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 11:23:04 +0300 Subject: [PATCH 25/51] fix(desktop): search card extends downward, tabs always visible - Feed tabs + compose button always visible (never hidden) - Search pill becomes active input when focused, card extends downward with history section below the always-visible header row - Scrim only covers feed content below, header stays sharp via zIndex(10f) - Cmd+F focuses search, Escape or click-scrim dismisses Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index af5436587e..35e5e16c84 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -813,45 +813,44 @@ private fun FeedTabsHeader( .zIndex(if (searchExpanded) 10f else 0f), ) { Column { + // Always-visible header row: tabs + search + compose Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - if (!searchExpanded) { - // Compact feed tabs - pinnedFeeds.forEach { feed -> - val isSelected = + // Feed tabs — always visible + pinnedFeeds.forEach { feed -> + val isSelected = + when (feed.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> + feedMode == FeedMode.FOLLOWING + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> + feedMode == FeedMode.GLOBAL + else -> activeFeedId == feed.id + } + FilterChip( + selected = isSelected, + onClick = { when (feed.source) { is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> - feedMode == FeedMode.FOLLOWING + onFeedModeChange(FeedMode.FOLLOWING) is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> - feedMode == FeedMode.GLOBAL - else -> activeFeedId == feed.id + onFeedModeChange(FeedMode.GLOBAL) + else -> onNavigateToFeed(feed) } - FilterChip( - selected = isSelected, - onClick = { - when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> - onFeedModeChange(FeedMode.FOLLOWING) - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> - onFeedModeChange(FeedMode.GLOBAL) - else -> onNavigateToFeed(feed) - } - }, - label = { - Text( - "${feed.emoji} ${feed.name}", - maxLines = 1, - style = MaterialTheme.typography.labelMedium, - ) - }, - ) - } + }, + label = { + Text( + "${feed.emoji} ${feed.name}", + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + }, + ) } - // Search pill / expanded input — takes remaining center space + // Search: pill when collapsed, active input when expanded if (searchExpanded) { SearchInput( onDismiss = { onSearchExpandedChange(false) }, @@ -865,19 +864,17 @@ private fun FeedTabsHeader( ) } - if (!searchExpanded) { - // Compose button - IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Compose", - modifier = Modifier.size(18.dp), - ) - } + // Compose button — always visible + IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Compose", + modifier = Modifier.size(18.dp), + ) } } - // Expanded search: history section below the input + // Card extends downward with history when search is focused if (searchExpanded) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) SearchHistorySection( From bec8fd81ea5dff22f1f64f0b79d1ad3530f8577e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 11:31:38 +0300 Subject: [PATCH 26/51] fix(desktop): search card floats above scrim, sidebar dims separately - Header card pulled OUT of ReadingColumn into outer Box layer 3 (rendered after scrim, so it floats visually above it) - Feed content in layer 1 with spacer for header height - Scrim in layer 2 covers only feed content - Sidebar gets its own scrim overlay via Box wrapper in Main.kt - Search card stays sharp/visible while everything else dims Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 104 ++++++++++-------- .../amethyst/desktop/ui/FeedScreen.kt | 60 +++++----- 2 files changed, 89 insertions(+), 75 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 6e5e2c6a3e..896fa9a5bc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1380,56 +1380,68 @@ fun MainContent( } } - MainSidebar( - activeNpub = accountManager.currentAccount()?.npub, - allAccounts = allAccountsState, - localCache = localCache, - onSwitchAccount = { npub -> - scope.launch(Dispatchers.IO) { - accountManager.switchAccount(npub) - } - }, - onAddAccount = { showAddAccountDialog = true }, - onRemoveAccount = { npub -> - scope.launch(Dispatchers.IO) { - accountManager.removeAccountFromStorage(npub) - } - }, - onAddColumn = onShowAppDrawer, - onOpenSettings = { - when (layoutMode) { - LayoutMode.DECK -> { - if (deckState.hasColumnOfType(DeckColumnType.Settings)) { - deckState.focusExistingColumn(DeckColumnType.Settings) - } else { - deckState.addColumn(DeckColumnType.Settings) + Box { + MainSidebar( + activeNpub = accountManager.currentAccount()?.npub, + allAccounts = allAccountsState, + localCache = localCache, + onSwitchAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.switchAccount(npub) + } + }, + onAddAccount = { showAddAccountDialog = true }, + onRemoveAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.removeAccountFromStorage(npub) + } + }, + onAddColumn = onShowAppDrawer, + onOpenSettings = { + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(DeckColumnType.Settings) } } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.Settings) - } - } - }, - onNavigate = { type -> - when (layoutMode) { - LayoutMode.DECK -> { - if (deckState.hasColumnOfType(type)) { - deckState.focusExistingColumn(type) - } else { - deckState.addColumn(type) + }, + onNavigate = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) } } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(type) - } - } - }, - activeColumnType = activeColumnType, - onShowImportFollowListDialog = onShowImportFollowListDialog, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - torStatus = torStatus, - ) + }, + activeColumnType = activeColumnType, + onShowImportFollowListDialog = onShowImportFollowListDialog, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + torStatus = torStatus, + ) + + // Dim sidebar when feed search is active + if (com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current.value) { + Box( + modifier = + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.3f)), + ) + } + } VerticalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 35e5e16c84..0f4afae494 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -581,35 +581,10 @@ fun FeedScreen( } Box(modifier = Modifier.fillMaxSize()) { + // Layer 1: Feed content (scrollable, behind scrim) ReadingColumn { - // Header: pinned feed tabs + search pill (or expanded search card) - FeedTabsHeader( - feedMode = feedMode, - activeFeedId = activeFeedId, - searchExpanded = searchActive, - onSearchExpandedChange = onSearchActiveChange, - onFeedModeChange = { mode -> - feedMode = mode - activeFeedId = null - activeFeedSource = null - if (mode != FeedMode.CUSTOM) { - DesktopPreferences.feedMode = mode - } - }, - onNavigateToFeed = { feed -> - val source = feed.source - if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) { - activeFeedId = feed.id - activeFeedSource = source - feedMode = FeedMode.CUSTOM - } - }, - onOpenFeedsDrawer = onOpenFeedsDrawer, - onCompose = onCompose, - onSearchClick = onSearchClick, - ) - - Spacer(Modifier.height(8.dp)) + // Reserve space for the header card that floats above + Spacer(Modifier.height(60.dp)) // Feed content based on FeedState when (val state = feedState) { @@ -748,7 +723,7 @@ fun FeedScreen( ) } - // Search scrim — dims feed content when search is expanded + // Layer 2: Search scrim — dims feed content when search is expanded if (searchActive) { Box( modifier = @@ -764,6 +739,33 @@ fun FeedScreen( ) } + // Layer 3: Header card — rendered AFTER scrim so it floats above it + FeedTabsHeader( + feedMode = feedMode, + activeFeedId = activeFeedId, + searchExpanded = searchActive, + onSearchExpandedChange = onSearchActiveChange, + onFeedModeChange = { mode -> + feedMode = mode + activeFeedId = null + activeFeedSource = null + if (mode != FeedMode.CUSTOM) { + DesktopPreferences.feedMode = mode + } + }, + onNavigateToFeed = { feed -> + val source = feed.source + if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) { + activeFeedId = feed.id + activeFeedSource = source + feedMode = FeedMode.CUSTOM + } + }, + onOpenFeedsDrawer = onOpenFeedsDrawer, + onCompose = onCompose, + onSearchClick = onSearchClick, + ) + // Lightbox overlay lightboxState?.let { state -> LightboxOverlay( From 02679cc5960b33cae27bbdf43565b8985df59d83 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 11:41:01 +0300 Subject: [PATCH 27/51] feat(desktop): animated search expand, tab-collapse, reuse SearchResultsList 1. AnimatedVisibility for search card expand/collapse (expandVertically + fadeIn 200ms, shrinkVertically + fadeOut 150ms) 2. Feed tab clicks now collapse the search (onSearchExpandedChange(false) before switching feed) 3. When typing in search: reuses SearchResultsList composable from search/ package with AdvancedSearchBarState (same as full SearchScreen) - Empty input shows search history (recent + saved) - Typing shows live people + note results grouped by kind - "Open full search" link at bottom for advanced filtering Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 297 ++++++++++-------- 1 file changed, 160 insertions(+), 137 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 0f4afae494..cd95d093b5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -20,6 +20,12 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea @@ -35,6 +41,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -56,6 +63,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -71,13 +79,13 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar @@ -105,6 +113,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor +import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -764,6 +773,9 @@ fun FeedScreen( onOpenFeedsDrawer = onOpenFeedsDrawer, onCompose = onCompose, onSearchClick = onSearchClick, + localCache = localCache, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, ) // Lightbox overlay @@ -799,10 +811,38 @@ private fun FeedTabsHeader( onOpenFeedsDrawer: () -> Unit, onCompose: () -> Unit, onSearchClick: () -> Unit = {}, + localCache: DesktopLocalCache? = null, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, ) { val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() val sidePadding = LocalReadingSidePadding.current + val scope = rememberCoroutineScope() + val searchState = remember { AdvancedSearchBarState(scope) } + var searchText by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + // Sync search text to AdvancedSearchBarState + LaunchedEffect(searchText.text) { + searchState.updateFromText(searchText.text) + } + + // Clear on collapse + LaunchedEffect(searchExpanded) { + if (!searchExpanded) { + searchText = TextFieldValue("") + searchState.clearSearch() + } + } + + // Auto-focus when expanded + LaunchedEffect(searchExpanded) { + if (searchExpanded) { + focusRequester.requestFocus() + } + } Surface( shape = MaterialTheme.shapes.medium, @@ -811,8 +851,7 @@ private fun FeedTabsHeader( modifier = Modifier .fillMaxWidth() - .padding(horizontal = sidePadding + 8.dp, vertical = 8.dp) - .zIndex(if (searchExpanded) 10f else 0f), + .padding(horizontal = sidePadding + 8.dp, vertical = 8.dp), ) { Column { // Always-visible header row: tabs + search + compose @@ -821,7 +860,7 @@ private fun FeedTabsHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - // Feed tabs — always visible + // Feed tabs — always visible. Clicking collapses search. pinnedFeeds.forEach { feed -> val isSelected = when (feed.source) { @@ -834,6 +873,7 @@ private fun FeedTabsHeader( FilterChip( selected = isSelected, onClick = { + onSearchExpandedChange(false) when (feed.source) { is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> onFeedModeChange(FeedMode.FOLLOWING) @@ -854,11 +894,58 @@ private fun FeedTabsHeader( // Search: pill when collapsed, active input when expanded if (searchExpanded) { - SearchInput( - onDismiss = { onSearchExpandedChange(false) }, - onOpenFullSearch = onSearchClick, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f), - ) + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + onSearchExpandedChange(false) + focusManager.clearFocus() + true + } else { + false + } + }, + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchText.text.isEmpty()) { + Text( + "Search notes, profiles, hashtags...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + innerTextField() + } + }, + ) + Spacer(Modifier.width(8.dp)) + Text( + if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } } else { com.vitorpamplona.amethyst.desktop.ui.search.SearchPill( onClick = { onSearchExpandedChange(true) }, @@ -876,94 +963,68 @@ private fun FeedTabsHeader( } } - // Card extends downward with history when search is focused - if (searchExpanded) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - SearchHistorySection( - onOpenFullSearch = { - onSearchExpandedChange(false) - onSearchClick() - }, - ) - } - } - } -} + // Animated expanding section: history or search results + AnimatedVisibility( + visible = searchExpanded, + enter = expandVertically(animationSpec = tween(200)) + fadeIn(tween(200)), + exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(tween(100)), + ) { + Column { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) -@Composable -private fun SearchInput( - onDismiss: () -> Unit, - onOpenFullSearch: () -> Unit, - modifier: Modifier = Modifier, -) { - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + val hasQuery = searchText.text.isNotBlank() + if (hasQuery) { + // Show search results (reuses SearchResultsList) + SearchResultsList( + state = searchState, + onNavigateToProfile = { pubkey -> + onSearchExpandedChange(false) + onNavigateToProfile(pubkey) + }, + onNavigateToThread = { noteId -> + onSearchExpandedChange(false) + onNavigateToThread(noteId) + }, + localCache = localCache, + modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(), + ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - Icon( - MaterialSymbols.Search, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - BasicTextField( - value = textFieldValue, - onValueChange = { textFieldValue = it }, - modifier = - Modifier - .weight(1f) - .focusRequester(focusRequester) - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.Escape -> { - onDismiss() - focusManager.clearFocus() - true - } - Key.Enter -> { - onOpenFullSearch() - true - } - else -> false - } - } else { - false + // "Open full search" link + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onSearchExpandedChange(false) + onSearchClick() + }.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.AutoMirrored.OpenInNew, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(10.dp)) + Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) } - }, - textStyle = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - singleLine = true, - decorationBox = { innerTextField -> - Box(contentAlignment = Alignment.CenterStart) { - if (textFieldValue.text.isEmpty()) { - Text( - "Search notes, profiles, hashtags...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + } else { + // Show search history when empty + SearchHistorySection( + onOpenFullSearch = { + onSearchExpandedChange(false) + onSearchClick() + }, ) } - innerTextField() } - }, - ) - Spacer(Modifier.width(8.dp)) - Text( - if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() + } + } } } @@ -994,12 +1055,7 @@ private fun SearchHistorySection(onOpenFullSearch: () -> Unit) { .clickable { onOpenFullSearch() } .padding(horizontal = 16.dp, vertical = 8.dp), ) { - Icon( - MaterialSymbols.History, - null, - Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Icon(MaterialSymbols.History, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.width(10.dp)) Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) } @@ -1008,62 +1064,29 @@ private fun SearchHistorySection(onOpenFullSearch: () -> Unit) { if (savedSearches.isNotEmpty()) { if (history.isNotEmpty()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } - Text( - "Saved", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - ) + Text("Saved", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) savedSearches.take(5).forEach { saved -> Row( verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { onOpenFullSearch() } - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().clickable { onOpenFullSearch() }.padding(horizontal = 16.dp, vertical = 8.dp), ) { - Icon( - MaterialSymbols.Bookmark, - null, - Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) + Icon(MaterialSymbols.Bookmark, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(10.dp)) Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) } } } - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) Row( verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clickable { onOpenFullSearch() } - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().clickable { onOpenFullSearch() }.padding(horizontal = 16.dp, vertical = 8.dp), ) { - Icon( - MaterialSymbols.AutoMirrored.OpenInNew, - null, - Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) + Icon(MaterialSymbols.AutoMirrored.OpenInNew, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(10.dp)) - Text( - "Open full search", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) + Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) } } } From e587bb946e96ec532cae18e4cda8db73e7555810 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 11:52:24 +0300 Subject: [PATCH 28/51] feat(desktop): wire relay subscriptions for inline search results FeedTabsHeader now creates actual NIP-50 relay subscriptions when typing in the inline search, matching the full SearchScreen wiring: - Collect debouncedQuery (300ms) from AdvancedSearchBarState - Access searchRelays from LocalRelayCategories - rememberSubscription for people search (MetadataEvent kind 0) - rememberSubscription for note search (SearchFilterFactory filters) - Results flow into SearchResultsList (reused from search/ package) - 10s timeout for silent relays - Subscriptions auto-cleanup on collapse via DisposableEffect Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index cd95d093b5..e93c93dac6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -79,6 +79,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -86,6 +87,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar @@ -102,11 +105,13 @@ import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay @@ -116,6 +121,7 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -773,6 +779,7 @@ fun FeedScreen( onOpenFeedsDrawer = onOpenFeedsDrawer, onCompose = onCompose, onSearchClick = onSearchClick, + relayManager = relayManager, localCache = localCache, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, @@ -811,6 +818,7 @@ private fun FeedTabsHeader( onOpenFeedsDrawer: () -> Unit, onCompose: () -> Unit, onSearchClick: () -> Unit = {}, + relayManager: DesktopRelayConnectionManager? = null, localCache: DesktopLocalCache? = null, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -823,6 +831,9 @@ private fun FeedTabsHeader( var searchText by remember { mutableStateOf(TextFieldValue("")) } val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current + val debouncedQuery by searchState.debouncedQuery.collectAsState() + val relayCategories = LocalRelayCategories.current + val searchRelays by relayCategories.searchRelays.collectAsState() // Sync search text to AdvancedSearchBarState LaunchedEffect(searchText.text) { @@ -844,6 +855,83 @@ private fun FeedTabsHeader( } } + // Start/stop relay search when debounced query changes + LaunchedEffect(debouncedQuery) { + if (!debouncedQuery.isEmpty) { + searchState.clearResults() + searchState.initRelayStates(searchRelays) + searchState.startSearching("people-search") + searchState.startSearching("adv-search") + kotlinx.coroutines.delay(10_000L) + searchState.timeoutWaitingRelays() + } + } + + // NIP-50 people search subscription + if (relayManager != null) { + rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) { + if (searchRelays.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + createSearchPeopleSubscription( + relays = searchRelays, + searchQuery = debouncedQuery.text.ifBlank { QuerySerializer.serialize(debouncedQuery) }, + limit = 10, + onEvent = { event, _, relay, _ -> + if (searchState.trackRelayEvent(relay.url, event.id)) { + if (event is MetadataEvent) { + localCache?.consumeMetadata(event) + val user = localCache?.getUserIfExists(event.pubKey) + if (user != null) { + searchState.addPeopleResult(user) + } + } + } + }, + onEose = { relay, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + searchState.stopSearching("people-search") + }, + onClosed = { relay, _, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.FAILED) + searchState.stopSearching("people-search") + }, + ) + } + + // NIP-50 note search subscription + rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) { + if (searchRelays.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + val filters = SearchFilterFactory.createFilters(debouncedQuery) + if (filters.isEmpty()) return@rememberSubscription null + + SubscriptionConfig( + subId = generateSubId("inline-search"), + filters = filters, + relays = searchRelays, + onEvent = { event, _, relay, _ -> + if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig + if (searchState.trackRelayEvent(relay.url, event.id)) { + val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) + if (filtered.isNotEmpty()) { + searchState.addNoteResults(filtered) + } + } + }, + onEose = { relay, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + searchState.stopSearching("adv-search") + }, + onClosed = { relay, _, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.FAILED) + searchState.stopSearching("adv-search") + }, + ) + } + } + Surface( shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surface, From e9756d41aab696a7d3f79d016cba2def345a0bf3 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 11:55:40 +0300 Subject: [PATCH 29/51] fix(desktop): add loading indicator + relay diagnostics for inline search - Show "Searching N relays..." with spinner while waiting for results - Show "No search relays configured" if searchRelays is empty - Observe isSearching, peopleResults, noteResults directly for state Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index e93c93dac6..2063e9594e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1061,7 +1061,32 @@ private fun FeedTabsHeader( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) val hasQuery = searchText.text.isNotBlank() + val isSearching by searchState.isSearching.collectAsState() + val people by searchState.peopleResults.collectAsState() + val notes by searchState.noteResults.collectAsState() + val hasResults = people.isNotEmpty() || notes.isNotEmpty() + if (hasQuery) { + if (isSearching && !hasResults) { + // Loading state while waiting for relay results + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Searching ${searchRelays.size} relays...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + // Show search results (reuses SearchResultsList) SearchResultsList( state = searchState, @@ -1077,6 +1102,15 @@ private fun FeedTabsHeader( modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(), ) + if (!isSearching && !hasResults && searchRelays.isEmpty()) { + Text( + "No search relays configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + // "Open full search" link HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant, From 28e8fc627e1b1768a1dc6d648e556fc4fc77a354 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:00:40 +0300 Subject: [PATCH 30/51] =?UTF-8?q?feat(desktop):=20polished=20inline=20sear?= =?UTF-8?q?ch=20states=20=E2=80=94=20loading,=20empty,=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LinearProgressIndicator at top of expanded card (animated in/out) - Loading state: centered icon + "Searching N relays..." - Empty state: "No results found" / "No search relays configured" - Results stream in incrementally from relays - 1s debounce for relay queries, save to search history (no dupes) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 99 ++++++++++++------- 1 file changed, 62 insertions(+), 37 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 2063e9594e..033f38bf93 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -1058,60 +1059,84 @@ private fun FeedTabsHeader( exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(tween(100)), ) { Column { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - val hasQuery = searchText.text.isNotBlank() val isSearching by searchState.isSearching.collectAsState() val people by searchState.peopleResults.collectAsState() val notes by searchState.noteResults.collectAsState() val hasResults = people.isNotEmpty() || notes.isNotEmpty() + // Linear progress bar at the top — visible while searching + AnimatedVisibility( + visible = isSearching || (hasQuery && !hasResults), + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + if (hasQuery) { - if (isSearching && !hasResults) { - // Loading state while waiting for relay results - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + // Results stream in as they arrive + if (hasResults) { + SearchResultsList( + state = searchState, + onNavigateToProfile = { pubkey -> + onSearchExpandedChange(false) + onNavigateToProfile(pubkey) + }, + onNavigateToThread = { noteId -> + onSearchExpandedChange(false) + onNavigateToThread(noteId) + }, + localCache = localCache, + modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(), + ) + } else if (isSearching) { + // Loading — centered in results area + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.height(8.dp)) Text( "Searching ${searchRelays.size} relays...", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } else { + // Search complete, no results + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Spacer(Modifier.height(8.dp)) + Text( + if (searchRelays.isEmpty()) "No search relays configured" else "No results found", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - // Show search results (reuses SearchResultsList) - SearchResultsList( - state = searchState, - onNavigateToProfile = { pubkey -> - onSearchExpandedChange(false) - onNavigateToProfile(pubkey) - }, - onNavigateToThread = { noteId -> - onSearchExpandedChange(false) - onNavigateToThread(noteId) - }, - localCache = localCache, - modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(), - ) - - if (!isSearching && !hasResults && searchRelays.isEmpty()) { - Text( - "No search relays configured", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } - - // "Open full search" link + // "Open full search" — always visible when there's a query HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), From 956eee67d7df6c0e400c24044f5c732afd3de8f0 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:01:26 +0300 Subject: [PATCH 31/51] feat(desktop): 1s debounce + auto-save search history on collapse - Increase relay query debounce from 300ms to 1000ms for inline search (reduces unnecessary relay load while typing) - Save query to SearchHistoryStore when search collapses (if non-empty) - SearchHistoryStore.addToHistory() already deduplicates by serialized query Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 033f38bf93..b730b2a08b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -828,7 +828,7 @@ private fun FeedTabsHeader( val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() val sidePadding = LocalReadingSidePadding.current val scope = rememberCoroutineScope() - val searchState = remember { AdvancedSearchBarState(scope) } + val searchState = remember { AdvancedSearchBarState(scope, debounceMs = 1000L) } var searchText by remember { mutableStateOf(TextFieldValue("")) } val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current @@ -841,9 +841,13 @@ private fun FeedTabsHeader( searchState.updateFromText(searchText.text) } - // Clear on collapse + // Save to history + clear on collapse LaunchedEffect(searchExpanded) { if (!searchExpanded) { + val query = searchState.query.value + if (!query.isEmpty) { + SearchHistoryStore.addToHistory(query) + } searchText = TextFieldValue("") searchState.clearSearch() } From 971d20be77cd3e6338737551d74bab6bcc94bb02 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:03:51 +0300 Subject: [PATCH 32/51] fix(desktop): revert relay debounce to 300ms, separate 1s history save - Relay query debounce stays at 300ms (AdvancedSearchBarState default) - Separate 1s debounce on searchText: when user stops typing for 1s, save the query to SearchHistoryStore (assumes intent confirmed) - LaunchedEffect(searchText.text) auto-cancels on each keystroke, so only fires after 1s of inactivity - No duplicates: addToHistory() deduplicates by serialized query Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index b730b2a08b..be97360a68 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -828,7 +828,7 @@ private fun FeedTabsHeader( val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() val sidePadding = LocalReadingSidePadding.current val scope = rememberCoroutineScope() - val searchState = remember { AdvancedSearchBarState(scope, debounceMs = 1000L) } + val searchState = remember { AdvancedSearchBarState(scope) } var searchText by remember { mutableStateOf(TextFieldValue("")) } val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current @@ -841,13 +841,20 @@ private fun FeedTabsHeader( searchState.updateFromText(searchText.text) } - // Save to history + clear on collapse - LaunchedEffect(searchExpanded) { - if (!searchExpanded) { + // Auto-save to history after 1s of no typing (separate from relay debounce) + LaunchedEffect(searchText.text) { + if (searchText.text.isNotBlank()) { + kotlinx.coroutines.delay(1000L) val query = searchState.query.value if (!query.isEmpty) { SearchHistoryStore.addToHistory(query) } + } + } + + // Clear on collapse + LaunchedEffect(searchExpanded) { + if (!searchExpanded) { searchText = TextFieldValue("") searchState.clearSearch() } From 5e128cb535877ae1dfa70a6281bdf07bf941940d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:07:05 +0300 Subject: [PATCH 33/51] fix(desktop): clear search scrim when navigating via sidebar Sidebar onNavigate callback now sets searchActiveState.value = false, clearing the feed search expansion and sidebar dim overlay when the user clicks any nav item (Home, Messages, Settings, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 896fa9a5bc..fbc81d68a7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1364,6 +1364,7 @@ fun MainContent( // Shared sidebar for both layout modes if (!isImmersive) { val allAccountsState by accountManager.allAccounts.collectAsState() + val searchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current var showAddAccountDialog by remember { mutableStateOf(false) } // Derive active column type based on layout mode @@ -1412,6 +1413,7 @@ fun MainContent( } }, onNavigate = { type -> + searchActiveState.value = false when (layoutMode) { LayoutMode.DECK -> { if (deckState.hasColumnOfType(type)) { From 55704d520b3164e1bf04ff9c493b8097def5bb64 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:12:36 +0300 Subject: [PATCH 34/51] =?UTF-8?q?fix(desktop):=20ripple/hover=20clipping?= =?UTF-8?q?=20=E2=80=94=20correct=20modifier=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SidebarNavItem/SidebarFeedItem: .clip().clickable().background() — ripple now clipped to RoundedCornerShape(8dp) bounds - ColumnHeader: .padding() before .pointerInput() — gesture detection respects horizontal padding - SearchPill: .clip(pill shape) before .hoverHighlight() — hover drawBehind rect clipped to pill shape, not parent rectangle Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt | 3 ++- .../com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt | 4 ++-- .../vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 629b5fa127..a857721427 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -58,11 +58,12 @@ fun ColumnHeader( .fillMaxWidth() .height(48.dp) .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 12.dp) .pointerInput(Unit) { detectTapGestures( onDoubleTap = { onDoubleClick() }, ) - }.padding(horizontal = 12.dp), + }, verticalAlignment = Alignment.CenterVertically, ) { if (hasBackStack) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 8ba2841162..8b909bfb23 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -417,8 +417,8 @@ private fun SidebarNavItem( .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp) .clip(RoundedCornerShape(8.dp)) - .background(backgroundColor) .clickable(onClick = onClick) + .background(backgroundColor) .onPointerEvent(PointerEventType.Enter) { isHovered = true } .onPointerEvent(PointerEventType.Exit) { isHovered = false } .padding(horizontal = 8.dp, vertical = 8.dp), @@ -489,8 +489,8 @@ private fun SidebarFeedItem( .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp) .clip(RoundedCornerShape(8.dp)) - .background(backgroundColor) .clickable(onClick = onClick) + .background(backgroundColor) .onPointerEvent(PointerEventType.Enter) { isHovered = true } .onPointerEvent(PointerEventType.Exit) { isHovered = false } .padding(horizontal = 8.dp, vertical = 8.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt index 24c7c0b2a9..71a51f668b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -49,7 +50,7 @@ fun SearchPill( onClick = onClick, shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = modifier.height(36.dp).hoverHighlight(), + modifier = modifier.height(36.dp).clip(RoundedCornerShape(999.dp)).hoverHighlight(), ) { Row( verticalAlignment = Alignment.CenterVertically, From c53c8238fb5769371b00ac2437cca4b201655479 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:15:10 +0300 Subject: [PATCH 35/51] fix(desktop): theming + modifier review fixes from multi-agent analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shapes: - DeckSidebar: RoundedCornerShape(8.dp) → MaterialTheme.shapes.small - NoteCard images: RoundedCornerShape(8.dp) → MaterialTheme.shapes.small Modifier patterns: - NoteCard: remove hardcoded .fillMaxWidth() — callers now pass it - FeedScreen NoteCard callers: add explicit Modifier.fillMaxWidth() Ripple clipping (from prior commit): - SearchPill: clip(pill shape) before hoverHighlight() - Sidebar items: clip before clickable - ColumnHeader: padding before pointerInput Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 2 ++ .../vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt | 5 ++--- .../com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index be97360a68..d0c4fd94ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -228,6 +228,7 @@ fun FeedNoteCard( val displayData = remember(originalEvent, metadataState) { originalEvent.toNoteDisplayData(localCache) } NoteCard( note = displayData, + modifier = Modifier.fillMaxWidth(), localCache = localCache, onClick = { onNavigateToThread(originalEvent.id) }, onAuthorClick = onNavigateToProfile, @@ -281,6 +282,7 @@ fun FeedNoteCard( val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) } NoteCard( note = displayData, + modifier = Modifier.fillMaxWidth(), localCache = localCache, onClick = { onNavigateToThread(event.id) }, onAuthorClick = onNavigateToProfile, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 8b909bfb23..e5f30019ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -41,7 +41,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -416,7 +415,7 @@ private fun SidebarNavItem( Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .clickable(onClick = onClick) .background(backgroundColor) .onPointerEvent(PointerEventType.Enter) { isHovered = true } @@ -488,7 +487,7 @@ private fun SidebarFeedItem( Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .clickable(onClick = onClick) .background(backgroundColor) .onPointerEvent(PointerEventType.Enter) { isHovered = true } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 0e0a9a4031..3529fbed08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -237,7 +237,7 @@ fun NoteCard( Modifier .fillMaxWidth() .heightIn(max = maxMediaHeight) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .then( if (onImageClick != null) { Modifier.clickable { onImageClick(imageUrls, index) } @@ -311,7 +311,7 @@ fun NoteCard( if (onClick != null) { OutlinedCard( onClick = onClick, - modifier = modifier.fillMaxWidth(), + modifier = modifier, colors = cardColors, border = cardBorder, shape = cardShape, @@ -319,7 +319,7 @@ fun NoteCard( ) } else { OutlinedCard( - modifier = modifier.fillMaxWidth(), + modifier = modifier, colors = cardColors, border = cardBorder, shape = cardShape, From c7483a4f0da9e2cba182a38d2ea4d8ba669dba53 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:18:23 +0300 Subject: [PATCH 36/51] fix(desktop): SearchPill hover bounds + AccountSwitcher ripple size - SearchPill: move hoverHighlight() inside Surface content Row so it's clipped to the pill's 36dp height (was drawing on parent Row height) - AccountSwitcherDropdown: reduce IconButton from 48dp to 40dp so ripple circle fits within collapsed sidebar width Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/account/AccountSwitcherDropdown.kt | 2 +- .../vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt index 03bff1775f..95eff2e687 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt @@ -99,7 +99,7 @@ fun AccountSwitcherDropdown( Box(modifier = modifier) { IconButton( onClick = { expanded = true }, - modifier = Modifier.size(48.dp), + modifier = Modifier.size(40.dp), ) { Icon( MaterialSymbols.Person, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt index 71a51f668b..c2751a6a04 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -50,11 +49,11 @@ fun SearchPill( onClick = onClick, shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = modifier.height(36.dp).clip(RoundedCornerShape(999.dp)).hoverHighlight(), + modifier = modifier.height(36.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), + modifier = Modifier.hoverHighlight().padding(horizontal = 12.dp), ) { Icon( MaterialSymbols.Search, From b8f6751a68bd08815aec7354c6e062ae7a075c7d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:20:44 +0300 Subject: [PATCH 37/51] =?UTF-8?q?fix(desktop):=20address=20P2=20todos=20?= =?UTF-8?q?=E2=80=94=20feed=20margin=20+=20history=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todo #001: Feed content margin under search - Animate spacer height: 60dp collapsed → 300dp expanded (tween 200ms) - Feed items no longer hidden by expanded search card Todo #002: History item click populates input - SearchHistorySection gains onHistoryItemClick callback - Clicking recent search populates searchText with query text - LaunchedEffect(searchText.text) triggers updateFromText() → relay search Todo #003: Cmd+F context (deferred — P2 stays open) - Currently Cmd+F toggles inline search on feeds only - On non-feed screens it's a no-op (user can click Search in sidebar) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index d0c4fd94ec..58eb443095 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -78,6 +79,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus @@ -602,7 +604,13 @@ fun FeedScreen( // Layer 1: Feed content (scrollable, behind scrim) ReadingColumn { // Reserve space for the header card that floats above - Spacer(Modifier.height(60.dp)) + // Reserve space for the header card that floats above. + // When search is expanded, the card grows — add more margin. + val headerSpacerHeight by animateDpAsState( + targetValue = if (searchActive) 300.dp else 60.dp, + animationSpec = tween(200), + ) + Spacer(Modifier.height(headerSpacerHeight)) // Feed content based on FeedState when (val state = feedState) { @@ -1180,6 +1188,9 @@ private fun FeedTabsHeader( onSearchExpandedChange(false) onSearchClick() }, + onHistoryItemClick = { text -> + searchText = TextFieldValue(text, TextRange(text.length)) + }, ) } } @@ -1189,7 +1200,10 @@ private fun FeedTabsHeader( } @Composable -private fun SearchHistorySection(onOpenFullSearch: () -> Unit) { +private fun SearchHistorySection( + onOpenFullSearch: () -> Unit, + onHistoryItemClick: (String) -> Unit = { }, +) { val history by SearchHistoryStore.history.collectAsState() val savedSearches by SearchHistoryStore.savedSearches.collectAsState() @@ -1212,7 +1226,7 @@ private fun SearchHistorySection(onOpenFullSearch: () -> Unit) { modifier = Modifier .fillMaxWidth() - .clickable { onOpenFullSearch() } + .clickable { onHistoryItemClick(text) } .padding(horizontal = 16.dp, vertical = 8.dp), ) { Icon(MaterialSymbols.History, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) From 41a59a94997eb999a8eaa7d6b3435260a20b2d34 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:24:14 +0300 Subject: [PATCH 38/51] fix(desktop): wire Open Full Search via LocalOpenFullSearch CompositionLocal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LocalOpenFullSearch CompositionLocal (navigates to Search column) - Provided at Main.kt level alongside LocalFeedSearchActive - FeedScreen reads it directly — no param threading needed - "Open full search" link in expanded header now actually opens Search column - Removed unused onOpenSearch param from DeckColumnContainer Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kotlin/com/vitorpamplona/amethyst/desktop/Main.kt | 7 ++++++- .../com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 3 ++- .../amethyst/desktop/ui/theme/AmethystSpacing.kt | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fbc81d68a7..b531a2a0d4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -452,7 +452,9 @@ fun main() { } else { KeyShortcut(Key.F, ctrl = true) }, - onClick = { feedSearchActiveState.value = !feedSearchActiveState.value }, + onClick = { + feedSearchActiveState.value = !feedSearchActiveState.value + }, ) Item( "App Drawer", @@ -620,6 +622,9 @@ fun main() { key(appRestartKey) { CompositionLocalProvider( com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive provides feedSearchActiveState, + com.vitorpamplona.amethyst.desktop.ui.theme.LocalOpenFullSearch provides { + navigateToScreen?.invoke(DeckColumnType.Search) + }, ) { App( layoutMode = layoutMode, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 58eb443095..332cd84430 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -342,6 +342,7 @@ fun FeedScreen( val feedSearchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current var searchActive by feedSearchActiveState val onSearchActiveChange: (Boolean) -> Unit = { searchActive = it } + val openFullSearch = com.vitorpamplona.amethyst.desktop.ui.theme.LocalOpenFullSearch.current val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() val followedUsers by localCache.followedUsers.collectAsState() @@ -789,7 +790,7 @@ fun FeedScreen( }, onOpenFeedsDrawer = onOpenFeedsDrawer, onCompose = onCompose, - onSearchClick = onSearchClick, + onSearchClick = openFullSearch, relayManager = relayManager, localCache = localCache, onNavigateToProfile = onNavigateToProfile, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt index 4e52e11e32..d1a47ead20 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt @@ -53,6 +53,9 @@ val LocalIsDarkTheme = staticCompositionLocalOf { false } /** Mutable state for Cmd+F feed search. Provided at Main.kt level. */ val LocalFeedSearchActive = compositionLocalOf { mutableStateOf(false) } +/** Callback to open the full Search column/screen. Provided at Main.kt level. */ +val LocalOpenFullSearch = compositionLocalOf<() -> Unit> { {} } + val MaterialTheme.spacing: AmethystSpacing @Composable @ReadOnlyComposable get() = LocalSpacing.current From 7ff4d8e3b740cf8aa79db4fe4b0d32faf1d3db7c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:31:37 +0300 Subject: [PATCH 39/51] fix(desktop): avatar ripple + P3 hardcoded colors + inline shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatar/Account switcher: - Rewrite SidebarAccountHeader with same shape/hover as other nav items - Entire row (avatar + display name) is clickable with rounded clip - Inline DropdownMenu replaces overlaid AccountSwitcherDropdown - Collapsed: compact avatar with same rounded hover treatment P3 #004 — Hardcoded status colors: - Add StatusGreen/StatusRed/StatusAmber to commons Colors.kt - Replace 32 inline Color() values across 10 files with theme tokens - Color.Red → MaterialTheme.colorScheme.error where appropriate - Color.Green/Gray → StatusGreen/onSurfaceVariant P3 #005 — Inline shapes: - RoundedCornerShape(8.dp) → MaterialTheme.shapes.small (~20 files) - RoundedCornerShape(12.dp) → MaterialTheme.shapes.medium - RoundedCornerShape(16.dp) → MaterialTheme.shapes.large - Pill shapes (100dp/999dp) kept as-is Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/commons/ui/theme/Colors.kt | 9 + .../amethyst/desktop/ui/ComposeNoteDialog.kt | 3 +- .../amethyst/desktop/ui/DevSettingsSection.kt | 28 +- .../amethyst/desktop/ui/LoginScreen.kt | 4 +- .../amethyst/desktop/ui/auth/LoginCard.kt | 3 +- .../desktop/ui/auth/LoginProgressSteps.kt | 12 +- .../desktop/ui/auth/NewKeyWarningCard.kt | 5 +- .../desktop/ui/chats/ChatFileAttachment.kt | 3 +- .../amethyst/desktop/ui/chats/ChatPane.kt | 4 +- .../amethyst/desktop/ui/deck/AppDrawer.kt | 10 +- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 158 ++-- .../desktop/ui/deck/FeedsDrawerTab.kt | 3 +- .../amethyst/desktop/ui/media/AudioPlayer.kt | 3 +- .../desktop/ui/media/DesktopVideoPlayer.kt | 4 +- .../desktop/ui/media/LightboxOverlay.kt | 3 +- .../desktop/ui/media/PictureDisplay.kt | 2 +- .../desktop/ui/note/DesktopRichTextViewer.kt | 7 +- .../desktop/ui/profile/EditProfileScreen.kt | 5 +- .../desktop/ui/profile/ProfileInfoCard.kt | 4 +- .../desktop/ui/relay/RelayStatusCard.kt | 15 +- .../desktop/ui/search/AdvancedSearchPanel.kt | 3 +- .../ui/settings/MediaServerSettings.kt | 9 +- .../desktop/ui/tor/TorStatusIndicator.kt | 12 +- .../desktop/ui/wallet/WalletColumnScreen.kt | 5 +- ...18-fix-macos-vlc-bundled-discovery-plan.md | 220 +++++ ...22-feat-desktop-note-action-bar-ux-plan.md | 299 +++++++ ...top-note-action-counters-and-quote-plan.md | 136 ++++ ...5-28-feat-desktop-search-spotlight-plan.md | 246 ++++++ ...28-feat-desktop-visual-personality-plan.md | 751 ++++++++++++++++++ ...26-05-28-fix-feed-header-search-ux-plan.md | 175 ++++ ...ing-p2-feed-content-margin-under-search.md | 34 + ...arch-history-item-click-populates-input.md | 34 + todos/003-pending-p2-cmd-f-context-aware.md | 35 + .../004-pending-p3-hardcoded-status-colors.md | 36 + todos/005-pending-p3-inline-shape-audit.md | 26 + 35 files changed, 2172 insertions(+), 134 deletions(-) create mode 100644 docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md create mode 100644 docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md create mode 100644 docs/plans/2026-05-22-fix-desktop-note-action-counters-and-quote-plan.md create mode 100644 docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md create mode 100644 docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md create mode 100644 docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md create mode 100644 todos/001-pending-p2-feed-content-margin-under-search.md create mode 100644 todos/002-pending-p2-search-history-item-click-populates-input.md create mode 100644 todos/003-pending-p2-cmd-f-context-aware.md create mode 100644 todos/004-pending-p3-hardcoded-status-colors.md create mode 100644 todos/005-pending-p3-inline-shape-audit.md diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt index 0c0a65da72..2d96b06cdc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -75,6 +75,15 @@ val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) val LightAllGoodColor = Color(0xFF339900) val DarkAllGoodColor = Color(0xFF99cc33) +// Semantic status colors for desktop +val StatusGreen = Color(0xFF4CAF50) +val StatusGreenDark = Color(0xFF81C784) +val StatusRed = Color(0xFFF44336) +val StatusRedDark = Color(0xFFEF9A9A) +val StatusAmber = Color(0xFFFFB300) +val StatusAmberDark = Color(0xFFFFD54F) +val StatusBlue = Color(0xFF2196F3) + // Fundraiser colors val LightFundraiserProgressColor = Color(0xFF3DB601) val DarkFundraiserProgressColor = Color(0xFF61A229) 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 9d8f5d3b65..135ad52fbe 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 @@ -36,7 +36,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -220,7 +219,7 @@ fun ComposeNoteDialog( .dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget) .then( if (isDragOver) { - Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium) } else { Modifier }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt index 4ab3216861..caa205a864 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt @@ -43,12 +43,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight 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.ui.theme.StatusAmber +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed import com.vitorpamplona.amethyst.desktop.account.AccountState import java.awt.Toolkit import java.awt.datatransfer.StringSelection @@ -70,11 +72,11 @@ fun DevSettingsSection( .fillMaxWidth() .border( width = 2.dp, - color = Color(0xFFFF9800), // Orange warning color - shape = RoundedCornerShape(8.dp), + color = StatusAmber, + shape = MaterialTheme.shapes.small, ).background( - color = Color(0xFF332200), // Dark orange tint - shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, ).padding(16.dp), ) { // Warning header @@ -85,13 +87,13 @@ fun DevSettingsSection( Icon( MaterialSymbols.Warning, contentDescription = "Warning", - tint = Color(0xFFFF9800), + tint = StatusAmber, ) Text( "Developer Settings", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = Color(0xFFFF9800), + color = StatusAmber, ) } @@ -105,7 +107,7 @@ fun DevSettingsSection( Spacer(Modifier.height(16.dp)) - HorizontalDivider(color = Color(0xFFFF9800).copy(alpha = 0.3f)) + HorizontalDivider(color = StatusAmber.copy(alpha = 0.3f)) Spacer(Modifier.height(16.dp)) @@ -114,7 +116,7 @@ fun DevSettingsSection( onClick = { showKeys = !showKeys }, colors = ButtonDefaults.outlinedButtonColors( - contentColor = Color(0xFFFF9800), + contentColor = StatusAmber, ), ) { Text(if (showKeys) "Hide Keys" else "Show Keys") @@ -180,7 +182,7 @@ private fun KeyRow( style = MaterialTheme.typography.labelMedium, color = if (isSensitive) { - Color(0xFFFF5252) + StatusRed } else { MaterialTheme.colorScheme.onSurface }, @@ -196,7 +198,7 @@ private fun KeyRow( ButtonDefaults.buttonColors( containerColor = if (copiedRecently) { - Color(0xFF4CAF50) + StatusGreen } else { MaterialTheme.colorScheme.primaryContainer }, @@ -222,7 +224,7 @@ private fun KeyRow( ), color = if (isSensitive) { - Color(0xFFFF5252) + StatusRed } else { MaterialTheme.colorScheme.onSurfaceVariant }, @@ -230,7 +232,7 @@ private fun KeyRow( Modifier .fillMaxWidth() .background( - color = Color(0xFF1A1A1A), + color = MaterialTheme.colorScheme.onBackground, shape = RoundedCornerShape(4.dp), ).padding(8.dp), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index e390b2df22..836a5cf546 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -44,13 +44,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color 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.login_subtitle_desktop import com.vitorpamplona.amethyst.commons.resources.login_title +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.RelayStatus @@ -203,7 +203,7 @@ fun ConnectingRelaysScreen( Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF4CAF50), + tint = StatusGreen, modifier = Modifier.size(14.dp), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index d6542a91d3..65c8fe7917 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -107,7 +106,7 @@ fun LoginCard( @Suppress("DEPRECATION") PrimaryTabRow( selectedTabIndex = selectedTab, - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)), + modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small), ) { tabs.forEachIndexed { index, title -> Tab( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt index 03b6b1d48d..c6ba44c469 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt @@ -34,10 +34,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color 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.ui.theme.StatusGreen import com.vitorpamplona.amethyst.desktop.account.LoginProgress import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus @@ -111,7 +111,7 @@ private fun StepRow(step: StepInfo) { Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF4CAF50), + tint = StatusGreen, modifier = Modifier.size(16.dp), ) } @@ -133,7 +133,7 @@ private fun StepRow(step: StepInfo) { style = MaterialTheme.typography.bodySmall, color = when (step.state) { - StepState.DONE -> Color(0xFF4CAF50) + StepState.DONE -> StatusGreen StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) }, @@ -155,7 +155,7 @@ private fun RelayRow( Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF2196F3), + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(12.dp), ) } @@ -164,7 +164,7 @@ private fun RelayRow( Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF4CAF50), + tint = StatusGreen, modifier = Modifier.size(12.dp), ) } @@ -206,7 +206,7 @@ private fun RelayRow( } RelayLoginStatus.EVENT_SENT -> { - Color(0xFF2196F3) + MaterialTheme.colorScheme.primary } else -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt index 573d035861..c985c2e451 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -76,7 +75,7 @@ fun NewKeyWarningCard( Text( stringResource(Res.string.new_key_warning_title), style = MaterialTheme.typography.titleMedium, - color = Color.Red, + color = MaterialTheme.colorScheme.error, ) Spacer(Modifier.height(16.dp)) @@ -102,7 +101,7 @@ fun NewKeyWarningCard( Text( stringResource(Res.string.new_key_secret_label), style = MaterialTheme.typography.labelMedium, - color = Color.Red, + color = MaterialTheme.colorScheme.error, ) SelectableKeyText(secretKey) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt index 83de8bbdc0..23cbe72d88 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -170,7 +169,7 @@ fun ChatFileAttachment( Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.FillWidth, ) } 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 da094091ca..09532abae1 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 @@ -203,7 +203,7 @@ fun ChatPane( target = dropTarget, ).then( if (isDragOver) { - Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) } else { Modifier }, @@ -657,7 +657,7 @@ private suspend fun sendWrappedReaction( @Composable private fun ReactionBar(onReaction: (String) -> Unit) { Surface( - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceVariant, shadowElevation = 2.dp, tonalElevation = 2.dp, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index aa7139cdbe..ac49cdc847 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -398,7 +398,7 @@ fun AppDrawer( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { /* consume click — prevent propagation to scrim */ }, - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, tonalElevation = 8.dp, ) { Column { @@ -579,7 +579,7 @@ private fun DrawerScreenCard( showMenu = true } }, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, tonalElevation = if (isSelected) 8.dp else 2.dp, color = if (isSelected) { @@ -792,7 +792,7 @@ private fun WorkspaceCard( .fillMaxWidth() .padding(vertical = 4.dp) .clickable(onClick = onSwitch), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, tonalElevation = if (isActive) 8.dp else 2.dp, color = if (isActive) { @@ -869,7 +869,7 @@ private fun AddWorkspaceCard( .fillMaxWidth() .padding(vertical = 4.dp) .clickable(enabled = enabled, onClick = onClick), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp, ) { Row( @@ -935,7 +935,7 @@ private fun WorkspaceEditorDialog( WorkspaceIcons.availableNames.forEach { iName -> Surface( modifier = Modifier.size(40.dp).clickable { iconName = iName }, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, color = if (isSelected(iName)) { MaterialTheme.colorScheme.primaryContainer diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index e5f30019ec..a33c5ecaad 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -42,6 +42,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -56,10 +58,12 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition @@ -73,7 +77,6 @@ import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop -import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.collections.immutable.ImmutableList @@ -275,6 +278,7 @@ fun MainSidebar( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun SidebarAccountHeader( activeNpub: String?, @@ -288,59 +292,58 @@ private fun SidebarAccountHeader( onAddAccount: () -> Unit, onRemoveAccount: (String) -> Unit, ) { - if (!expanded) { - // Collapsed: just the account switcher dropdown (icon-only) - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - AccountSwitcherDropdown( - activeNpub = activeNpub, - allAccounts = allAccounts, - localCache = localCache, - onSwitchAccount = onSwitchAccount, - onAddAccount = onAddAccount, - onRemoveAccount = onRemoveAccount, - ) + var showDropdown by remember { mutableStateOf(false) } + var isHovered by remember { mutableStateOf(false) } + + val hoverBg = + if (isHovered) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + } else { + Color.Transparent } - } else { - // Expanded: avatar + name + npub, tappable to open account switcher - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Avatar - if (pubkeyHex != null) { - UserAvatar( - userHex = pubkeyHex, - pictureUrl = avatarUrl, - size = 40.dp, - contentDescription = "Account avatar", + + Box { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(MaterialTheme.shapes.small) + .clickable { showDropdown = true } + .background(hoverBg) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Avatar + if (pubkeyHex != null) { + UserAvatar( + userHex = pubkeyHex, + pictureUrl = avatarUrl, + size = if (expanded) 32.dp else 28.dp, + contentDescription = "Account avatar", + ) + } else { + Box( + modifier = + Modifier + .size(if (expanded) 32.dp else 28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + MaterialSymbols.Person, + contentDescription = "Account", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), ) - } else { - Box( - modifier = - Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - Icon( - MaterialSymbols.Person, - contentDescription = "Account", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) - } } + } + if (expanded) { Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { Text( text = displayName ?: "Account", @@ -360,16 +363,42 @@ private fun SidebarAccountHeader( } } } + } - // Overlay the dropdown so clicking opens it - AccountSwitcherDropdown( - activeNpub = activeNpub, - allAccounts = allAccounts, - localCache = localCache, - onSwitchAccount = onSwitchAccount, - onAddAccount = onAddAccount, - onRemoveAccount = onRemoveAccount, - modifier = Modifier.matchParentSize(), + // Account switcher dropdown menu + DropdownMenu( + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + offset = DpOffset(x = if (expanded) 240.dp else 56.dp, y = 0.dp), + ) { + allAccounts.forEach { account -> + val isActive = account.npub == activeNpub + DropdownMenuItem( + text = { + Text( + text = resolveDisplayNameForSidebar(account.npub, localCache) ?: npubShortForSidebar(account.npub), + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + ) + }, + onClick = { + showDropdown = false + onSwitchAccount(account.npub) + }, + trailingIcon = { + if (isActive) { + Icon(MaterialSymbols.Check, contentDescription = "Active", modifier = Modifier.size(16.dp)) + } + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Add Account") }, + onClick = { + showDropdown = false + onAddAccount() + }, + leadingIcon = { Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(16.dp)) }, ) } } @@ -532,3 +561,16 @@ private fun npubShortForSidebar(npub: String): String { if (npub.length <= 20) return npub return "${npub.take(10)}...${npub.takeLast(6)}" } + +private fun resolveDisplayNameForSidebar( + npub: String, + localCache: DesktopLocalCache?, +): String? { + if (localCache == null) return null + val pubkeyHex = + com.vitorpamplona.quartz.nip19Bech32 + .decodePublicKeyAsHexOrNull(npub) ?: return null + val user = localCache.getUserIfExists(pubkeyHex) ?: return null + val name = user.toBestDisplayName() + return name.takeIf { it != user.pubkeyDisplayHex() } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt index bcfc46cd52..9613b37164 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -302,7 +301,7 @@ private fun FeedRow( Modifier .fillMaxWidth() .clickable(onClick = onSelect), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, tonalElevation = 1.dp, ) { Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt index ce757e85c6..9d8f2b4771 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt @@ -26,7 +26,6 @@ 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider @@ -59,7 +58,7 @@ fun AudioPlayer( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index ae3b082bcb..a71c4024df 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -111,7 +111,7 @@ fun DesktopVideoPlayer( .height(constrainedHeight) .background( MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(8.dp), + MaterialTheme.shapes.small, ), contentAlignment = Alignment.Center, ) { @@ -123,7 +123,7 @@ fun DesktopVideoPlayer( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt index 44d2a20157..5032375b28 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton @@ -421,7 +420,7 @@ fun LightboxOverlay( Modifier .align(Alignment.BottomCenter) .padding(16.dp) - .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) + .background(Color.Black.copy(alpha = 0.5f), MaterialTheme.shapes.large) .padding(horizontal = 16.dp, vertical = 6.dp), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt index ba2159ccca..59eb0475b6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt @@ -72,7 +72,7 @@ fun PictureDisplay( .heightIn(max = 500.dp) .clip( if (index == 0 && title == null && description.isBlank()) { - RoundedCornerShape(8.dp) + MaterialTheme.shapes.small } else if (index == 0) { RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) } else { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt index 9039978f28..c0dff43f36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme @@ -171,7 +170,7 @@ fun DesktopRichTextViewer( Modifier .weight(1f) .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .then( if (callbacks.onImageClick != null) { Modifier.clickable { callbacks.onImageClick.invoke(urls, index) } @@ -349,7 +348,7 @@ private fun RenderSegment( Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) } @@ -362,7 +361,7 @@ private fun RenderSegment( Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) } 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 3d174db984..6962ea6512 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 @@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -528,7 +527,7 @@ fun EditProfileContent( Modifier .fillMaxWidth() .height(120.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .border( width = if (isBannerDragOver) 2.dp else 1.dp, color = @@ -537,7 +536,7 @@ fun EditProfileContent( } else { MaterialTheme.colorScheme.outline }, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, ).clickable { onPickBanner() } .dragAndDropTarget( shouldStartDragAndDrop = { true }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt index 95da5b8d24..8dbc844c57 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt @@ -31,8 +31,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber /** * Card displaying Nostr account key information. @@ -57,7 +57,7 @@ fun ProfileInfoCard( Text( "Read-only mode", style = MaterialTheme.typography.labelMedium, - color = Color.Yellow, + color = StatusAmber, ) Spacer(Modifier.height(8.dp)) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt index e23da3f123..175a179ff9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt @@ -35,11 +35,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color 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.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed import com.vitorpamplona.amethyst.desktop.network.RelayStatus import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion @@ -73,9 +74,9 @@ fun RelayStatusCard( ) { val statusColor = when { - status.connected -> Color.Green - status.error != null -> Color.Red - else -> Color.Gray + status.connected -> StatusGreen + status.error != null -> StatusRed + else -> MaterialTheme.colorScheme.onSurfaceVariant } if (status.connected) { @@ -114,9 +115,9 @@ fun RelayStatusCard( val torState = LocalTorState.current val badgeColor = if (torState.status is TorServiceStatus.Off) { - Color(0xFFF44336) // Red — Tor required but off + StatusRed // Tor required but off } else { - Color(0xFF4CAF50) // Green — routed via Tor + StatusGreen // routed via Tor } val badgeText = if (torState.status is TorServiceStatus.Off) "Requires Tor" else "via Tor" @@ -138,7 +139,7 @@ fun RelayStatusCard( Text( error, style = MaterialTheme.typography.bodySmall, - color = Color.Red.copy(alpha = 0.8f), + color = MaterialTheme.colorScheme.error, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt index 15a0e5412e..e34e821565 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AssistChip import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -79,7 +78,7 @@ fun AdvancedSearchPanel( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt index 82f4c782d1..69c273472d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -57,10 +57,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color 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.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -263,9 +264,9 @@ private fun ServerRow( shape = CircleShape, color = when (status) { - ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) - ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) - ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) + ServerHealthCheck.ServerStatus.ONLINE -> StatusGreen + ServerHealthCheck.ServerStatus.OFFLINE -> StatusRed + ServerHealthCheck.ServerStatus.UNKNOWN -> MaterialTheme.colorScheme.onSurfaceVariant }, ) {} } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt index 84fe2a1d63..89a3c2b284 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt @@ -33,12 +33,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpOffset 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.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed /** * Small shield icon showing Tor connection status in the sidebar footer. @@ -54,19 +56,19 @@ fun TorStatusIndicator( val (icon, tint, tooltip) = when (status) { is TorServiceStatus.Off -> { - Triple(MaterialSymbols.Shield, Color.Gray, "Tor: Off") + Triple(MaterialSymbols.Shield, MaterialTheme.colorScheme.onSurfaceVariant, "Tor: Off") } is TorServiceStatus.Connecting -> { - Triple(MaterialSymbols.Shield, Color(0xFFFFB300), "Tor: Connecting...") + Triple(MaterialSymbols.Shield, StatusAmber, "Tor: Connecting...") } is TorServiceStatus.Active -> { - Triple(MaterialSymbols.Shield, Color(0xFF4CAF50), "Tor: Connected") + Triple(MaterialSymbols.Shield, StatusGreen, "Tor: Connected") } is TorServiceStatus.Error -> { - Triple(MaterialSymbols.Shield, Color(0xFFF44336), "Tor: ${status.message}") + Triple(MaterialSymbols.Shield, StatusRed, "Tor: ${status.message}") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index 7b11e0e50e..eb2c16e705 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -631,7 +630,7 @@ private fun SendDialog( Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) { Card( modifier = Modifier.width(480.dp), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, ) { Column(modifier = Modifier.padding(24.dp)) { // Header @@ -849,7 +848,7 @@ private fun ReceiveDialog( Dialog(onDismissRequest = { if (!isGenerating) onDismiss() }) { Card( modifier = Modifier.width(400.dp), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, ) { Column(modifier = Modifier.padding(24.dp)) { // Header: title + close X diff --git a/docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md b/docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md new file mode 100644 index 0000000000..3f80a65e19 --- /dev/null +++ b/docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md @@ -0,0 +1,220 @@ +--- +title: "fix: macOS bundled VLC discovery broken by versioned setenv symbol" +type: fix +status: active +date: 2026-05-18 +deepened: 2026-05-18 +origin: docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md +--- + +# fix: macOS bundled VLC discovery broken by versioned setenv symbol + +## Enhancement Summary + +**Deepened on:** 2026-05-18 +**Research agents used:** VLC --plugin-path audit, macOS JNA symbol research, VlcjPlayerPool init flow audit + +### Key Improvements from Research +1. `--plugin-path` factory arg is unreliable — VLC needs `VLC_PLUGIN_PATH` env var set before `libvlc_new()` +2. `--reset-plugins-cache` is VLC CLI-only, not available via libvlc/vlcj factory args +3. Simpler fix: replace `LibC.INSTANCE.setenv()` with JNA-free env var setting + +## Overview + +Bundled VLC video playback is broken on macOS release builds. +`MacOsVlcDiscoverer.setPluginPath()` calls `LibC.INSTANCE.setenv()` via JNA, +but macOS 13+ uses versioned C symbols (`setenv$3b99ba0d`) that JNA's `dlsym` +cannot resolve. Without system VLC installed, video is completely unavailable. + +(See brainstorm: `docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md`) + +## Problem Statement + +``` +VLC: bundled discovery threw Error looking up function 'setenv$3b99ba0d': + dlsym(0x..., setenv$3b99ba0d): symbol not found +VLC: init failed — ...DiscoveryDirectoryProvider: Provider ...not found +``` + +- Affects all macOS DMG users without system VLC.app installed +- Audio playback works (separate factory, no plugin path needed for audio codecs) +- Graceful degradation exists (shows "Install VLC" message) but shouldn't be needed + +## Proposed Solution (Updated from Research) + +**Replace `LibC.INSTANCE.setenv()` with a JNA-free approach to set the env var.** + +VLC requires `VLC_PLUGIN_PATH` as a process-level environment variable BEFORE +`libvlc_new()` (called by `MediaPlayerFactory`). The `--plugin-path` factory arg +is NOT reliably forwarded to the plugin scanner. So we must set the actual env var, +just without using JNA's broken `LibC.setenv` binding. + +### Approach: Use JNA's lower-level Native.getLibrary to call setenv directly + +Instead of `LibC.INSTANCE.setenv()` which goes through vlcj's `LibC` interface +binding (which triggers the versioned symbol lookup), use JNA's `Function.getFunction` +to call `setenv` from libc directly without the problematic interface mapping: + +```kotlin +override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + return try { + // Call setenv directly via JNA Function API, bypassing LibC interface + // which fails on macOS 13+ due to versioned symbol lookup + val setenv = com.sun.jna.Function.getFunction("c", "setenv") + setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 + } catch (e: Throwable) { + // Fallback: set as JVM system property for factory arg approach + System.setProperty("vlc.plugin.path", pluginPath) + true + } +} +``` + +**If that still hits the symbol issue**, the alternative fallback is: + +```kotlin +override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + // Store for VlcjPlayerPool to pass as --plugin-path factory arg + discoveredPluginPath = pluginPath + return true +} +``` + +Then in `VlcjPlayerPool.init()`, pass `--plugin-path` to both factories as belt-and-suspenders. + +### Plugin Cache Fix + +Since `--reset-plugins-cache` is VLC CLI-only (not available via libvlc), fix stale +cache by deleting the cache file before factory creation: + +```kotlin +// Delete stale VLC plugin cache before factory init +val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") +cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } +``` + +## Implementation + +### File 1: `MacOsVlcDiscoverer.kt` + +**Current** (line 55): +```kotlin +override fun setPluginPath(pluginPath: String?): Boolean = + LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0 +``` + +**Change to:** +```kotlin +var discoveredPluginPath: String? = null + private set + +override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + discoveredPluginPath = pluginPath + return try { + // Direct JNA Function call bypasses vlcj's LibC interface binding + // which fails on macOS 13+ (versioned symbol setenv$3b99ba0d) + val setenv = com.sun.jna.Function.getFunction("c", "setenv") + setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 + } catch (_: Throwable) { + // If JNA call fails, store path for --plugin-path fallback + false + } +} +``` + +Remove import: `uk.co.caprica.vlcj.binding.lib.LibC` + +### File 2: `VlcjPlayerPool.kt` + +**Changes to `init()` (lines 77-113):** + +1. Hold reference to `MacOsVlcDiscoverer` for plugin path access: +```kotlin +val macOsDiscoverer = MacOsVlcDiscoverer() +val nd = NativeDiscovery(BundledVlcDiscoverer(), macOsDiscoverer) +``` + +2. Build factory args with plugin path fallback: +```kotlin +val factoryArgs = mutableListOf("--no-xlib") + +// If setenv failed, pass --plugin-path as fallback +val pluginPath = macOsDiscoverer.discoveredPluginPath + ?: System.getProperty("vlc.plugin.path") + ?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" } + +if (pluginPath != null && !envVarSetSuccessfully) { + factoryArgs += "--plugin-path=$pluginPath" +} + +val f = MediaPlayerFactory(*factoryArgs.toTypedArray()) +``` + +3. Delete stale plugin cache before factory creation (macOS only): +```kotlin +if ("mac" in System.getProperty("os.name").lowercase()) { + val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") + cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } +} +``` + +4. Audio factory also gets plugin path if env var wasn't set: +```kotlin +val audioArgs = mutableListOf("--no-video", "--no-xlib") +if (pluginPath != null && !envVarSetSuccessfully) { + audioArgs += "--plugin-path=$pluginPath" +} +MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it } +``` + +### File 3: `desktopApp/build.gradle.kts` + +Add JVM property as ultimate fallback: +```kotlin +jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins" +``` + +## Edge Cases from Research + +| Edge Case | Handling | +|-----------|----------| +| `Function.getFunction("c", "setenv")` also fails on macOS | Caught by try/catch, falls back to `--plugin-path` factory arg | +| `--plugin-path` not honored by VLC 3.0.20 | JVM property `-Dvlc.plugin.path` as ultimate fallback | +| Audio factory created later without plugin path | Audio factory also receives `--plugin-path` if env var wasn't set | +| `findVlcDir()` returns root, not plugins dir | Append `/plugins` when building `--plugin-path` value | +| VLC plugin cache stale after VLC update | Cache deleted on startup before factory creation | +| NativeDiscovery swallows MacOsVlcDiscoverer ref | Restructured to hold ref before passing to NativeDiscovery | + +## Acceptance Criteria + +- [ ] Video plays in release DMG on macOS without system VLC installed +- [ ] No `setenv` symbol errors in console output +- [ ] No stale plugin cache warnings on launch +- [ ] Audio playback still works +- [ ] Linux/Windows builds unaffected +- [ ] `./gradlew :desktopApp:run` (debug) still works +- [ ] Fallback chain works: direct setenv → --plugin-path → JVM property + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| `Function.getFunction("c", "setenv")` may also hit versioned symbol | Medium | Triple fallback: direct call → --plugin-path → JVM property | +| `--plugin-path` ignored by some VLC builds | Low | Env var approach is primary, --plugin-path is fallback only | +| Plugin cache deletion too aggressive | Low | Only deletes `plugins*` files in VLC cache dir, not other data | + +## Sources + +- **Origin brainstorm:** [docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md](../brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md) +- **Research:** VLC requires `VLC_PLUGIN_PATH` env var before `libvlc_new()` — `--plugin-path` not reliably forwarded +- **Research:** `--reset-plugins-cache` is CLI-only, not available via libvlc/vlcj +- **Research:** `setPluginPath()` called DURING `nd.discover()`, before factory creation +- **Research:** macOS 13+ versioned symbols affect JNA's `LibC` interface, but `Function.getFunction` may bypass it +- `MacOsVlcDiscoverer.kt:55` — failing `setenv` call +- `VlcjPlayerPool.kt:68-114` — init flow +- `VlcResourceResolver.kt` — returns VLC root dir (not plugins subdir) +- [JNA Issue #1423](https://github.com/java-native-access/jna/issues/1423) — macOS symbol resolution changes +- [Guardsquare/proguard#460](https://github.com/Guardsquare/proguard/issues/460) — related ProGuard bytecode rewriting diff --git a/docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md b/docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md new file mode 100644 index 0000000000..be0b8d2843 --- /dev/null +++ b/docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md @@ -0,0 +1,299 @@ +--- +title: "feat: Desktop note action bar — long-press details + right-click customize" +type: feat +status: active +date: 2026-05-22 +origin: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md +deepened: 2026-05-22 +--- + +# Desktop Note Action Bar — Long-Press Details + Right-Click Customize + +## Enhancement Summary + +**Deepened on:** 2026-05-22 +**Sections enhanced:** 4 +**Research agents used:** compose-expert, desktop-expert, compose-modifier-and-layout-style, compose-side-effects + +### Key Improvements from Research +1. Use `sealed class ActivePopup` for mutually exclusive popup state +2. Use `DropdownMenu` for simple option lists (emoji picker, repost), `Popup` for rich content (zap/reaction details) +3. Preserve ripple by explicitly passing `indication = ripple(bounded = false, radius = 16.dp)` when replacing `IconButton` +4. Mark `ZapReceipt` as `@Immutable`, consider `ImmutableList` for list params + +### New Considerations Discovered +- `Popup` creates a separate AWT window on JVM — always pass `PopupProperties(focusable = true)` for dismiss-on-click-outside +- Modifier chain order: `combinedClickable` before `onPointerEvent` (ripple wraps full area) +- Skip popup animations on desktop — instant feels right, `fadeIn(tween(100))` at most +- Metadata loading in popup: key `LaunchedEffect` on note ID, not popup visibility + +## Overview + +Add long-press popups and right-click customization to the desktop note action bar. Currently click = action and right-click = custom zap dialog. After this change, every action icon supports three gestures: click (action), long-press (view details), right-click (customize). + +(see brainstorm: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md) + +## Interaction Model + +| Action | Click | Long Press (~500ms) | Right-Click | +|--------|-------|---------------------|-------------| +| Reply | Open reply composer | Open thread | — | +| Like | React with + | Floating popup: who reacted + emoji | Emoji picker (DropdownMenu) | +| Repost | Repost | — | Quote/Fork (DropdownMenu) | +| Zap | Quick zap 21 sats | Floating popup: zap receipts | Custom zap dialog (existing) | +| Bookmark | Public/private dialog | — | — | + +## Technical Approach + +### Phase 1: Long-Press Detection + Zap Receipts Popup + +**Goal:** Add long-press to zap icon showing floating zap receipts popup. + +#### Step 1.1: Pass `Note` to NoteActionsRow + +Currently `NoteActionsRow` receives `zapReceipts: List = emptyList()` — callers pass empty lists. The actual zap/reaction data lives on the `Note` object. + +**Change:** Add `note: Note? = null` parameter to `NoteActionsRow`. + +**Files:** +- `desktopApp/src/jvmMain/.../ui/NoteActions.kt` — add `note: Note? = null` param +- `desktopApp/src/jvmMain/.../ui/FeedScreen.kt` — pass `note` (already available) +- Others keep default null (BookmarksScreen, ReadsScreen, ArticleReaderScreen) + +#### Step 1.2: Popup State as Sealed Class + +Replace individual boolean states with a single sealed class to ensure mutual exclusivity: + +```kotlin +sealed class ActivePopup { + data object None : ActivePopup() + data object ZapReceipts : ActivePopup() + data object Reactions : ActivePopup() + data object EmojiPicker : ActivePopup() + data object RepostOptions : ActivePopup() +} +var activePopup by remember { mutableStateOf(ActivePopup.None) } +``` + +#### Step 1.3: Replace `IconButton` with `combinedClickable` Box + +Replace the zap `IconButton` with `Box` + `combinedClickable`. Preserve ripple explicitly. + +```kotlin +Box( + modifier = Modifier + .size(32.dp) + .combinedClickable( + onClick = { /* quick zap */ }, + onLongClick = { activePopup = ActivePopup.ZapReceipts }, + indication = ripple(bounded = false, radius = 16.dp), + interactionSource = remember { MutableInteractionSource() }, + ) + .onPointerEvent(PointerEventType.Press) { pointerEvent -> + if (pointerEvent.buttons.isSecondaryPressed) { + showZapDialog = true + } + }, + contentAlignment = Alignment.Center, +) { + Icon(Zap, ...) +} +``` + +**Research insight:** `combinedClickable` before `onPointerEvent` in chain — ripple wraps full area, right-click handler sits inside. + +#### Step 1.4: Zap Receipts Floating Popup + +New composable using `Popup` + `ElevatedCard` (rich scrollable content): + +```kotlin +@Composable +fun ZapReceiptsPopup( + note: Note, + localCache: DesktopLocalCache, + onDismiss: () -> Unit, +) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(0, -popupHeightPx), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), // required for desktop dismiss + ) { + ElevatedCard( + modifier = Modifier.widthIn(max = 280.dp), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(max = 300.dp) + .padding(12.dp), + ) { + // Header: total sats + // Sorted receipts: sender name + amount + message + } + } + } +} +``` + +**Data access from Note:** +```kotlin +val zapEntries = note.zaps.mapNotNull { (request, receipt) -> + val sender = request.author?.toBestDisplayName() + ?: request.event?.pubKey?.take(8) + ?: return@mapNotNull null + val amount = (receipt?.event as? LnZapEvent)?.amount?.toLong() + ?: return@mapNotNull null + Triple(sender, amount, request.event?.content?.ifBlank { null }) +}.sortedByDescending { it.second } +``` + +**Metadata loading:** Use `LaunchedEffect(note.idHex)` to fetch unknown sender metadata. Coroutine auto-cancels when popup leaves composition. + +**Empty state:** If `zapEntries` is empty, show "No zaps yet" text. + +### Phase 2: Reactions Popup + +#### Step 2.1: `combinedClickable` for Like Icon + +Same pattern as zap — click = react, long-press = `activePopup = ActivePopup.Reactions`. + +#### Step 2.2: Reactions Floating Popup + +Same `Popup` + `ElevatedCard` pattern. Content grouped by emoji: + +```kotlin +@Composable +fun ReactionsPopup( + note: Note, + onDismiss: () -> Unit, +) { + // note.reactions: Map> + Popup( + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + ElevatedCard { + Column(Modifier.verticalScroll(rememberScrollState()).heightIn(max = 300.dp)) { + // Header: total count + note.reactions.forEach { (emoji, reactionNotes) -> + // Section: emoji + list of sender names + } + } + } + } +} +``` + +**Research insight:** Use `derivedStateOf` for the grouped reaction view to avoid recomposition on every upstream emission. + +### Phase 3: Right-Click Emoji Picker + +#### Step 3.1: Add right-click to Like Icon + +Add `onPointerEvent` secondary press → `activePopup = ActivePopup.EmojiPicker`. + +#### Step 3.2: Emoji Picker as DropdownMenu + +Use `DropdownMenu` (not raw `Popup`) — matches existing `AccountSwitcherDropdown` pattern. Simple option list. + +```kotlin +DropdownMenu( + expanded = activePopup is ActivePopup.EmojiPicker, + onDismissRequest = { activePopup = ActivePopup.None }, +) { + listOf("+", "\u2764\ufe0f", "\ud83e\udd19", "\ud83d\udd25", "\ud83d\udc40", "\ud83d\ude02").forEach { emoji -> + DropdownMenuItem( + text = { Text(emoji, fontSize = 20.sp) }, + onClick = { + activePopup = ActivePopup.None + onReact(emoji) + }, + ) + } +} +``` + +### Phase 4: Right-Click Repost Options + +#### Step 4.1: Repost DropdownMenu + +Right-click on repost icon → `DropdownMenu` with Quote / Fork options: + +```kotlin +DropdownMenu( + expanded = activePopup is ActivePopup.RepostOptions, + onDismissRequest = { activePopup = ActivePopup.None }, +) { + DropdownMenuItem(text = { Text("Repost") }, onClick = { /* repost */ }) + DropdownMenuItem(text = { Text("Quote") }, onClick = { /* quote */ }) +} +``` + +--- + +## File Changes Summary + +| File | Changes | +|------|---------| +| `NoteActions.kt` | Add `note: Note?` param, `ActivePopup` sealed class, replace `IconButton` with `combinedClickable` for zap/like/repost, add `ZapReceiptsPopup`, `ReactionsPopup`, emoji picker, repost options. Mark `ZapReceipt` as `@Immutable`. | +| `FeedScreen.kt` | Pass `note` to `NoteActionsRow` | + +## Acceptance Criteria + +- [ ] Long-press (~500ms) on zap icon shows floating popup with zap receipts (sender, amount, message) +- [ ] Long-press on like icon shows floating popup with reactions grouped by emoji +- [ ] Long-press on reply icon opens thread (same as click) +- [ ] Right-click on zap icon opens custom zap dialog (existing behavior preserved) +- [ ] Right-click on like icon opens emoji picker (DropdownMenu) +- [ ] Right-click on repost icon shows Quote/Fork options (DropdownMenu) +- [ ] Single click still works for all actions (zap, react, repost, reply, bookmark) +- [ ] Popups are mutually exclusive (opening one closes others) +- [ ] Popups dismiss on click outside (`PopupProperties(focusable = true)`) +- [ ] Popups are scrollable when content exceeds 300dp +- [ ] No crash when long-pressing on notes with 0 zaps/reactions (empty state) +- [ ] Ripple preserved on all action icons +- [ ] Compiles, spotless clean, tests pass + +## Implementation Order + +1. **Phase 1** — Zap long-press popup (highest value, proves the pattern) +2. **Phase 2** — Reactions popup (same pattern, different data) +3. **Phase 3** — Emoji picker (small addition) +4. **Phase 4** — Repost options (nice-to-have) + +## Technical Notes from Research + +### Modifier Chain Order +``` +Modifier + .size(32.dp) // identity: fixed touch target + .combinedClickable(...) // identity: click + long-press + .onPointerEvent(Press) { ... } // identity: right-click +``` +`combinedClickable` first (provides ripple/indication), `onPointerEvent` after. + +### Popup vs DropdownMenu Decision +| Content Type | API | +|-------------|-----| +| Rich scrollable content (zap receipts, reactions) | `Popup` + `ElevatedCard` | +| Simple option list (emoji picker, repost options) | `DropdownMenu` + `DropdownMenuItem` | + +### Desktop-Specific +- `PopupProperties(focusable = true)` is **required** on JVM desktop for click-outside dismiss +- `Popup` creates separate AWT window — can extend beyond parent window bounds (good) +- Skip animations or use `fadeIn(tween(100))` at most — desktop users expect crisp/instant + +### Side Effects in Popups +- Metadata loading: `LaunchedEffect(note.idHex)` — auto-cancels when popup leaves composition +- No `DisposableEffect` needed unless opening relay subscriptions +- Use `derivedStateOf` for grouped reaction view + +## Sources + +- **Origin brainstorm:** [docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md](docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md) — interaction model, popup style, per-action behavior +- Android `ReactionsRow.kt` — `combinedClickable`, `Popup`, animation patterns +- Android `Note.kt` — `reactions: Map>`, `zaps: Map` +- Desktop `NoteActions.kt` — `onPointerEvent` right-click, `ZapReceiptsDialog` +- Desktop `AccountSwitcherDropdown.kt` — `DropdownMenu` scroll/height pattern +- Desktop `ChatBubbleLayout.kt:139` — `combinedClickable` proven on JVM desktop diff --git a/docs/plans/2026-05-22-fix-desktop-note-action-counters-and-quote-plan.md b/docs/plans/2026-05-22-fix-desktop-note-action-counters-and-quote-plan.md new file mode 100644 index 0000000000..facca72812 --- /dev/null +++ b/docs/plans/2026-05-22-fix-desktop-note-action-counters-and-quote-plan.md @@ -0,0 +1,136 @@ +--- +title: "fix: Desktop note action counters, quote boost, and boost detail popup" +type: fix +status: active +date: 2026-05-22 +--- + +# Fix Desktop Note Action Counters, Quote Boost, and Boost Detail Popup + +## Problems + +### 1. Counters show zero for reactions, zaps, replies, reposts + +Counts are passed as **static Int/Long parameters** to `NoteActionsRow`. They're read once at render time from `note.countReactions()`, `note.zaps.size`, etc. When new events arrive via relay subscriptions, the FlowSet invalidates — triggering recomposition of FeedNoteCard — but the counts are re-read from the same snapshot. The actual issue is **timing**: interaction subscriptions (`requestInteractions()`) fire for visible notes, but events may arrive after the initial render. + +Additionally, **reply subscriptions are missing** — `DesktopRelaySubscriptionsCoordinator.requestInteractions()` subscribes for kinds 7, 9735, 6 but NOT kind 1 (replies). + +### 2. Quote boost does nothing + +The "Quote" `DropdownMenuItem` onClick just copies a note link to clipboard. It doesn't open a compose dialog. `ComposeNoteDialog` has no `quote` parameter. + +### 3. Boost long-press has no popup + +Repost icon has no `onLongClick` handler — long-press does nothing. Should show who boosted. + +## Technical Approach + +### Phase 1: Fix counters — make them reactive + +**Root cause:** FeedNoteCard reads counts once as vals, passes them as params. Even though FlowSet observations are collected, the count vals are re-read from Note's mutable properties which DO update — but only if the recomposition actually re-reads them. + +**Fix:** The FlowSet observation pattern is actually correct — collecting `flowSet.reactions.stateFlow` triggers recomposition which re-reads `note.countReactions()`. The problem may be that interaction events haven't arrived yet. + +**Step 1.1: Add kind 1 (replies) to interaction subscriptions** + +File: `desktopApp/src/jvmMain/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt` + +In `requestInteractions()`, add kind 1 to the filter list: +```kotlin +Filter(kinds = listOf(1), tags = mapOf("e" to noteIds)), // Replies +``` + +**Step 1.2: Verify flow collection triggers recomposition** + +In `FeedScreen.kt`, the pattern is: +```kotlin +val reactionsState by flowSet.reactions.stateFlow.collectAsState() +// ... +val reactionCount = note.countReactions() +``` + +This should work — `collectAsState()` triggers recomposition, which re-reads `countReactions()`. If it's not working, add `reactionsState` as a key to `remember`: +```kotlin +val reactionCount = remember(reactionsState) { note.countReactions() } +``` + +### Phase 2: Fix quote boost + +**Step 2.1: Add `quoteOf` parameter to ComposeNoteDialog** + +File: `desktopApp/src/jvmMain/.../ui/ComposeNoteDialog.kt` + +Add `quoteOf: Event? = null` parameter. When set, embed `nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}` in the initial text and add "q" tag. + +**Step 2.2: Wire Quote menu item to compose dialog** + +File: `desktopApp/src/jvmMain/.../ui/NoteActions.kt` + +Replace clipboard copy with opening ComposeNoteDialog: +```kotlin +var quoteEvent by remember { mutableStateOf(null) } + +// In Quote DropdownMenuItem onClick: +quoteEvent = event +activePopup = ActivePopup.None + +// Render dialog: +if (quoteEvent != null) { + ComposeNoteDialog(quoteOf = quoteEvent, onDismiss = { quoteEvent = null }, ...) +} +``` + +**Step 2.3: Build quote event with "q" tag** + +When composing, use TextNoteEvent builder with "q" tag: +```kotlin +TextNoteEvent.build( + message = "$userMessage\nnostr:${NEvent.create(quotedEvent.id, ...)}", + tags = arrayOf(arrayOf("q", quotedEvent.id, relayHint ?: "", quotedEvent.pubKey)), + signer = signer, +) +``` + +### Phase 3: Add boost detail popup + +**Step 3.1: Add long-press to repost icon** + +Same `combinedClickable` pattern. Long-press sets `activePopup = ActivePopup.Boosts`. + +**Step 3.2: Create BoostsPopup** + +New composable showing who boosted: +```kotlin +@Composable +fun BoostsPopup(note: Note, onDismiss: () -> Unit) { + // note.boosts: List + // Each boost Note has .author → display name + Popup(properties = PopupProperties(focusable = true)) { + ElevatedCard { ... } + } +} +``` + +Add `Boosts` to the `ActivePopup` sealed class. + +## File Changes + +| File | Changes | +|------|---------| +| `DesktopRelaySubscriptionsCoordinator.kt` | Add kind 1 to `requestInteractions()` filter | +| `FeedScreen.kt` | Ensure count reads are keyed on flow state | +| `NoteActions.kt` | Wire quote to compose dialog, add boost long-press popup, add `ActivePopup.Boosts` | +| `ComposeNoteDialog.kt` | Add `quoteOf: Event?` param, embed quote in text + "q" tag | + +## Acceptance Criteria + +- [ ] Reaction count updates when new reactions arrive via relay +- [ ] Zap count/amount updates when new zaps arrive +- [ ] Reply count shows and updates +- [ ] Repost count shows and updates +- [ ] Kind 1 replies subscribed in interaction filters +- [ ] Quote menu item opens ComposeNoteDialog with quoted note +- [ ] Quote posts include "q" tag and embedded nostr: URI +- [ ] Long-press repost icon shows popup with who boosted +- [ ] Empty state for boosts popup: "No reposts yet" +- [ ] Compiles, spotless, tests pass diff --git a/docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md b/docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md new file mode 100644 index 0000000000..9e4d8b4eb3 --- /dev/null +++ b/docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md @@ -0,0 +1,246 @@ +--- +title: "feat: Desktop Search Spotlight + Unified Feed Header Bar" +type: feat +status: active +date: 2026-05-28 +origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md +--- + +# feat: Desktop Search Spotlight + Unified Feed Header Bar + +## Overview + +Add a global search spotlight overlay (Cmd+F) and a unified feed header bar that combines feed tabs with a search pill. Also fix the bug where "+ Add more" feeds only works from the Home feed by moving it to the sidebar. + +Three components: +1. **SearchSpotlight** — global overlay with scrim, auto-focus, recent/saved searches, live results +2. **FeedHeaderBar** — unified feed tabs (My Feed | Global | pinned custom) + search pill, used on every feed column +3. **Sidebar "+ Add Feed"** — move from broken feed header to always-accessible sidebar + +(see brainstorm: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md) + +## Problem Statement + +1. **No quick search** — searching requires opening a full Search column. No spotlight/command-palette UX. +2. **Feed tabs disconnected from search** — the screenshot reference shows tabs + search in a unified bar, but current feed columns have separate headers with no search pill. +3. **"+ Add more" bug** — the button to add custom feeds only works on the Home feed; from Global or custom feeds it's broken/invisible. + +## Proposed Solution + +### Search Spotlight (Cmd+F) + +Global overlay that dims content (50% scrim), shows a centered search card (~600dp wide, 20% from top): +- **Empty state:** Recent searches + saved searches from `SearchHistoryStore` +- **Typing:** Live people + note results (reuse `AdvancedSearchBarState` with 300ms debounce) +- **Result selection:** Opens new column (deck) or navigates (single-pane) +- **"Open full search"** link at bottom → opens Search column with current query +- **Keyboard:** Escape closes, arrow keys navigate results, Enter selects + +### Unified Feed Header Bar + +Replaces the current `ColumnHeader` on feed-type columns: +``` +┌──────────────────────────────────────────────┐ +│ [My Feed] [Global] [Custom1] 🔍 Search.. ⌘F │ +└──────────────────────────────────────────────┘ +``` +- Left: feed tabs (Following + Global always, up to 3 pinned custom feeds) +- Right: compact SearchPill that opens the spotlight +- Active tab: `primary` color indicator +- 48dp height, `surfaceContainer` background + +### Sidebar "+ Add Feed" + +Move from feed column header to MainSidebar's FEEDS section — always accessible. + +## Technical Approach + +### Implementation Phases + +#### Phase 1: SearchSpotlight Composable + +New file: `desktopApp/.../ui/search/SearchSpotlight.kt` + +**Architecture:** +- `Dialog` with custom `Surface` (not `AlertDialog` — need full layout control) +- Scrim: `Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)).clickable { onDismiss() })` +- Search card: `Surface(shape = shapes.large, color = surface)` centered with `600.dp` max width +- Input: `BasicTextField` with pill decoration (matching SearchScreen pattern), auto-focused via `FocusRequester` +- State: Create `AdvancedSearchBarState(scope)` scoped to spotlight lifecycle. `DisposableEffect` stops subscriptions on close. +- Results: `LazyColumn` with sections (People max 5, Notes max 5), each item clickable +- History: Read from `SearchHistoryStore` on open + +**Key composables:** +```kotlin +@Composable +fun SearchSpotlight( + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + account: AccountState.LoggedIn, + searchHistoryStore: SearchHistoryStore, + onSelectProfile: (String) -> Unit, // pubkey hex + onSelectNote: (String) -> Unit, // note id hex + onSelectHashtag: (String) -> Unit, // tag + onOpenFullSearch: (String) -> Unit, // query text + onDismiss: () -> Unit, +) +``` + +**Success criteria:** +- [ ] Spotlight opens centered with scrim dimming background +- [ ] Input auto-focused on open +- [ ] Recent + saved searches shown before typing +- [ ] Live people/note results appear while typing (300ms debounce) +- [ ] Selecting result calls appropriate callback and closes +- [ ] Escape closes spotlight +- [ ] Arrow key navigation through results +- [ ] "Open full search" opens Search column with query + +#### Phase 2: SearchPill Composable + +New file: `desktopApp/.../ui/search/SearchPill.kt` + +Small reusable pill: +```kotlin +@Composable +fun SearchPill( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) +``` +- Pill shape (999dp corners), `surfaceContainerHigh` background +- Search icon + "Search..." + "⌘F" hint +- Height: 36dp +- Hover highlight via `hoverHighlight()` modifier + +**Success criteria:** +- [ ] Renders as compact pill with search icon and shortcut hint +- [ ] Click opens spotlight +- [ ] Hover highlights + +#### Phase 3: FeedHeaderBar Composable + +New file: `desktopApp/.../ui/search/FeedHeaderBar.kt` + +Unified header for feed columns: +```kotlin +@Composable +fun FeedHeaderBar( + feedTabs: ImmutableList, + activeFeedId: String, + onTabClick: (String) -> Unit, + onSearchClick: () -> Unit, + modifier: Modifier = Modifier, +) + +data class FeedTab( + val id: String, + val label: String, + val isBuiltIn: Boolean = false, // Following/Global are built-in +) +``` + +**Layout:** +- `Row` with `surfaceContainer` background, 48dp height +- Left: scrollable `Row` of tab chips/buttons +- Right: `SearchPill` with fixed width +- Active tab: filled with `primary`, others `onSurfaceVariant` +- Divider at bottom: 1dp `outlineVariant` + +**Feed tabs source:** +- "My Feed" (Following) — always first, `id = "following"` +- "Global" — always second, `id = "global"` +- Pinned custom feeds from `LocalFeedRepository.current` (max 3) + +**Integration:** Replace `ColumnHeader` for `DeckColumnType.HomeFeed`, `DeckColumnType.GlobalFeed`, and `DeckColumnType.CustomFeed` in `DeckColumnContainer.kt`. + +**Success criteria:** +- [ ] Feed tabs render with Following + Global + up to 3 pinned +- [ ] Active tab highlighted with primary color +- [ ] Tab click switches feed +- [ ] Search pill visible on right side +- [ ] Shown on all feed-type columns + +#### Phase 4: Sidebar "+ Add Feed" + Keyboard Shortcut + +**MainSidebar (DeckSidebar.kt):** +- Add "+ Add Feed" item at bottom of FEEDS section +- Click opens feed builder/drawer (existing `onOpenFeedsDrawer` callback) +- Styled as subtle text link with `+` icon + +**Main.kt:** +- Add `Cmd+F` keyboard shortcut in MenuBar +- Add `showSearchSpotlight` state +- Render `SearchSpotlight` when `showSearchSpotlight` is true +- Wire result callbacks to `deckState.addColumn()` / `singlePaneState.navigate()` + +**FeedScreen.kt:** +- Remove inline "+ Add more" button (now in sidebar) + +**Success criteria:** +- [ ] Cmd+F opens spotlight from anywhere in the app +- [ ] "+ Add Feed" visible in sidebar FEEDS section +- [ ] "+ Add Feed" works regardless of current feed view +- [ ] Old "+ Add more" button removed from feed headers + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Cmd+F opens search spotlight overlay with scrim +- [ ] Spotlight shows recent + saved searches on open +- [ ] Live search results (people + notes) with 300ms debounce +- [ ] Selecting a profile opens it (new column in deck, navigate in single-pane) +- [ ] Selecting a note opens thread +- [ ] "Open full search" opens Search column with query +- [ ] Escape closes spotlight +- [ ] FeedHeaderBar shows feed tabs + search pill on all feed columns +- [ ] Tab switching works (Following ↔ Global ↔ Custom feeds) +- [ ] "+ Add Feed" in sidebar works from any screen +- [ ] Keyboard arrow navigation through spotlight results + +### Non-Functional Requirements + +- [ ] Spotlight opens in <100ms (no relay calls until user types) +- [ ] Scrim renders at 60fps +- [ ] Search results appear within 300ms of typing pause +- [ ] Compiles on all platforms (macOS, Windows, Linux) +- [ ] Spotless clean + +## Dependencies + +- `AdvancedSearchBarState` (commons/) — reuse, no changes needed +- `SearchHistoryStore` (desktop) — reuse, no changes needed +- `SearchFilterFactory` (desktop) — reuse for relay subscriptions +- `FeedDefinitionRepository` (commons/) — read pinned feeds for tabs +- `MainSidebar` — add "+ Add Feed" item +- `hoverHighlight()` modifier — already created in visual personality PR + +## Files Affected + +| File | Action | +|------|--------| +| New: `desktopApp/.../ui/search/SearchSpotlight.kt` | Spotlight overlay | +| New: `desktopApp/.../ui/search/SearchPill.kt` | Compact pill component | +| New: `desktopApp/.../ui/search/FeedHeaderBar.kt` | Unified feed tabs + search | +| `desktopApp/.../Main.kt` | Cmd+F shortcut, spotlight state, render, result callbacks | +| `desktopApp/.../ui/deck/DeckColumnContainer.kt` | Use FeedHeaderBar for feed columns | +| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add "+ Add Feed" to FEEDS section | +| `desktopApp/.../ui/FeedScreen.kt` | Remove "+ Add more" if present, adapt header | +| `desktopApp/.../ui/deck/ColumnHeader.kt` | May need trailing slot for non-feed columns | + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md](docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md) — Key decisions: Cmd+F spotlight, unified FeedHeaderBar, sidebar "+ Add Feed" + +### Internal References + +- `SearchScreen.kt` — existing full search column (keep as-is) +- `AdvancedSearchBarState.kt` — reusable search state management +- `SearchHistoryStore.kt` — recent/saved search persistence +- `SearchFilterFactory.kt` — NIP-50 relay filter construction +- `FeedDefinitionRepository.kt` — pinned custom feeds source +- `MainSidebar (DeckSidebar.kt)` — sidebar where "+ Add Feed" moves to diff --git a/docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md b/docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md new file mode 100644 index 0000000000..031de753ae --- /dev/null +++ b/docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md @@ -0,0 +1,751 @@ +--- +title: "feat: Desktop Visual Personality Overhaul" +type: feat +status: active +date: 2026-05-28 +origin: docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md +--- + +# feat: Desktop Visual Personality Overhaul + +## Enhancement Summary + +**Deepened on:** 2026-05-28 +**Research agents used:** compose-expert, desktop-expert, compose-stability-diagnostics, compose-modifier-and-layout-style, best-practices-researcher, color-audit-explorer + +### Critical Fixes (from research) +1. **`ColorScheme.isLight` doesn't exist in M3** — use `LocalIsDarkTheme` CompositionLocal instead +2. **Hover modifier won't compile** — `@Composable` lambda can't invoke in `drawBehind`; `shape.topStart` invalid on generic Shape. Rewrite using `onPointerEvent` (codebase convention) +3. **Sidebar animation thrashes column widths** — `fitColumnsToWidth()` fires every frame during 240→56dp transition. Add 300ms debounce +4. **NoteCard/NoteCardSkeleton missing `modifier` parameter** — required for reusable composables +5. **AccountSwitcher dropdown offset hardcoded to 48dp** — needs dynamic offset based on sidebar width + +### Key Improvements +- Use `onPointerEvent` for hover (project convention, not `composed{}`) +- Add `clipToBounds()` on sidebar during animation +- Add keyboard shortcuts: `Cmd/Ctrl+B` (sidebar toggle), `Cmd/Ctrl+K` (search focus) +- Gate SegmentedButton on column width > 400dp +- `remember` BorderStroke to avoid instance churn defeating skip optimization +- ShimmerPlaceholder in `commons/commonMain` (pure M3, no platform APIs) +- Use `LocalScrollbarStyle` for scrollbar customization (built-in API) +- Add `LocalIsDarkTheme` CompositionLocal alongside `LocalSpacing` +- SegmentedButton confirmed available and stable (already used in AppDrawer.kt) +- 32 inline `Color()` literals to migrate across 10 files + +### Color Audit Results +- **32 inline Color() constructors** across 10 desktop files (status indicators: green/red/amber) +- **48 `.copy(alpha=)` patterns** across 18 files (standardize with alpha constants) +- **Key files:** DevSettingsSection (10), LoginProgressSteps (5), NamecoinSettingsSection (4), TorStatusIndicator (3) + +## Overview + +Transform Amethyst Desktop from an OS-native-adaptive look into a distinctly branded experience. Replace per-OS color schemes with a unified Amethyst identity (cyan/blue accent), redesign the sidebar to 240dp with labels, restyle cards to flat+border, add hover effects, skeleton loading, and polish all UI components. + +Desktop-only scope — Android unchanged. (see brainstorm: Decision #5) + +## Problem Statement + +The current desktop theme adapts to each OS (macOS, GNOME, KDE, Windows) with per-OS colors, shapes, and fonts. While technically impressive, this makes the app visually neutral — it doesn't feel like "Amethyst." The 56dp icon-only sidebar wastes desktop screen space and hurts discoverability. Cards use shadow elevation that renders inconsistently on desktop JVM/Skia. + +## Proposed Solution + +A 6-phase implementation that progresses from theme foundation → spacing → sidebar → cards → column headers → polish. Each phase builds on the previous, and the app remains functional throughout. + +## Technical Approach + +### Architecture + +The overhaul touches 3 layers: + +1. **Theme layer** (`desktopApp/.../platform/`) — ColorScheme, Typography, Shapes +2. **Layout layer** (`desktopApp/.../ui/deck/`) — Sidebar, ColumnHeader, DeckLayout +3. **Component layer** (`desktopApp/.../ui/note/`, shared composables) — NoteCard, action bar, dialogs + +New additions: +- `AmethystSpacing` CompositionLocal for design tokens +- `Modifier.hoverHighlight()` shared hover utility +- `ShimmerPlaceholder` composable for loading states + +### Implementation Phases + +--- + +#### Phase 1: Theme Foundation + +**Goal:** Replace all per-OS color schemes with unified Amethyst brand. Unified shapes and typography weights. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../platform/PlatformColorScheme.kt` | Replace 10 per-OS functions with `amethystLight()` + `amethystDark()` | +| `desktopApp/.../platform/PlatformShapes.kt` | Replace per-OS shapes with unified tokens | +| `desktopApp/.../platform/PlatformTypography.kt` | Standardize weight scale across all OS | +| `commons/.../ui/theme/Colors.kt` | Add cyan/blue brand constants | + +**Color Scheme (see brainstorm: Layer 1):** + +```kotlin +// commons/ui/theme/Colors.kt — new constants +val AmethystBlue = Color(0xFF0096FF) // light primary +val AmethystBlueDark = Color(0xFF4DB8FF) // dark primary +val AmethystPurple = Color(0xFF9A82DB) // tertiary (heritage) + +// PlatformColorScheme.kt — amethystLight() +fun amethystLight() = lightColorScheme( + primary = AmethystBlue, // #0096FF + onPrimary = Color.White, + primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White), + onPrimaryContainer = AmethystBlue, + secondary = Color(0xFF5E8FAD), // desaturated blue + onSecondary = Color.White, + tertiary = AmethystPurple, // heritage purple + onTertiary = Color.White, + background = Color(0xFFF2F2F7), // light gray + onBackground = Color(0xFF1C1C1E), + surface = Color.White, + onSurface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFFF0F0F5), + onSurfaceVariant = Color(0xFF6E6E73), + surfaceContainer = Color(0xFFF7F7FA), + surfaceContainerHigh = Color(0xFFEEEEF2), + surfaceContainerHighest = Color(0xFFE5E5EA), + surfaceContainerLow = Color(0xFFFAFAFC), + outline = Color(0xFFE0E0E0), + outlineVariant = Color(0xFFEBEBEB), + error = Color(0xFFBA1A1A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), +) + +// amethystDark() — same structure with dark values +fun amethystDark() = darkColorScheme( + primary = AmethystBlueDark, // #4DB8FF + onPrimary = Color.White, + primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onPrimaryContainer = AmethystBlueDark, + secondary = Color(0xFF7EAEC8), + onSecondary = Color.White, + tertiary = Color(0xFFB6A0E0), // lighter purple + onTertiary = Color.White, + background = Color(0xFF121212), + onBackground = Color(0xFFE5E5EA), + surface = Color(0xFF1E1E1E), + onSurface = Color(0xFFE5E5EA), + surfaceVariant = Color(0xFF2A2A2A), + onSurfaceVariant = Color(0xFF9E9EA3), + surfaceContainer = Color(0xFF252525), + surfaceContainerHigh = Color(0xFF2E2E2E), + surfaceContainerHighest = Color(0xFF383838), + surfaceContainerLow = Color(0xFF1A1A1A), + outline = Color(0xFF3A3A3A), + outlineVariant = Color(0xFF2E2E2E), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), +) +``` + +**`resolve()` simplification:** +```kotlin +fun resolve(isDark: Boolean): ColorScheme = + if (isDark) amethystDark() else amethystLight() +``` + +Remove: `macOsLight()`, `macOsDark()`, `gnomeLight()`, `gnomeDark()`, `kdeLight()`, `kdeDark()`, `windowsLight()`, `windowsDark()`, `genericLight()`, `genericDark()`, `darkenForLight()`, accent parameter from `resolve()`. Keep `onAccent()` only if still needed elsewhere. + +**Shapes (unified):** +```kotlin +// PlatformShapes.kt +val current = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), +) +``` + +Remove all per-OS shape functions. + +**Typography weights:** +- Display: `FontWeight.Light` (300) +- Headline: `FontWeight.SemiBold` (600) — keep +- Title: `FontWeight.SemiBold` / `FontWeight.Medium` — keep +- Body: `FontWeight.Normal` — keep +- Label: `FontWeight.Medium` — keep +- Standardize letter spacing to `-0.3sp` for display/headline across all OS + +Keep per-OS font family detection — fonts ARE platform-specific. + +**PlatformTheme.kt changes:** +- Remove accent resolution (`PlatformAccent.systemAccent()`) +- Simplify to `PlatformColorScheme.resolve(isDark)` (no accent param) +- Keep `titleBarInsetTop` and `applyNativeWindowChrome()` + +**Migration:** Grep `desktopApp/` for inline `Color(0xFF...)` constructors and replace with `MaterialTheme.colorScheme.*` references where possible. + +**Success criteria:** +- [ ] App compiles with unified color scheme on all OS +- [ ] Dark/light mode toggle works correctly +- [ ] No per-OS color/shape variance remains +- [ ] Typography weights follow standardized scale + +--- + +#### Phase 2: Spacing System + +**Goal:** Create a design token system for consistent spacing across all desktop UI. + +**Files:** + +| File | Action | +|------|--------| +| New: `desktopApp/.../ui/theme/AmethystSpacing.kt` | Create spacing tokens + CompositionLocal | +| `desktopApp/.../platform/PlatformTheme.kt` | Provide `LocalSpacing` | + +**Implementation:** + +```kotlin +// AmethystSpacing.kt +@Immutable +data class AmethystSpacing( + val xxs: Dp = 2.dp, + val xs: Dp = 4.dp, + val sm: Dp = 8.dp, + val md: Dp = 12.dp, + val lg: Dp = 16.dp, + val xl: Dp = 24.dp, + val xxl: Dp = 32.dp, + val cardPadding: Dp = 16.dp, + val cardGap: Dp = 8.dp, + val sidebarExpandedWidth: Dp = 240.dp, + val sidebarCollapsedWidth: Dp = 56.dp, + val columnHeaderHeight: Dp = 48.dp, +) + +val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() } + +val MaterialTheme.spacing: AmethystSpacing + @Composable @ReadOnlyComposable + get() = LocalSpacing.current +``` + +**In PlatformTheme.kt:** +```kotlin +val LocalIsDarkTheme = staticCompositionLocalOf { false } + +// In PlatformMaterialTheme: +CompositionLocalProvider( + LocalSpacing provides AmethystSpacing(), + LocalIsDarkTheme provides isDark, +) { + MaterialTheme(colorScheme, typography, shapes, content) +} +``` + +> **Research insight:** `ColorScheme.isLight` does not exist in Material3 Compose (M2 only). Provide `LocalIsDarkTheme` alongside `LocalSpacing` so dialogs/components can detect dark mode. `staticCompositionLocalOf` is correct — matches `LocalDesktopCache`, `LocalRelayManager` pattern in Main.kt. + +**Success criteria:** +- [ ] `MaterialTheme.spacing.*` accessible throughout desktop app +- [ ] `LocalIsDarkTheme.current` available for dark mode detection +- [ ] No functional changes — this is infrastructure for subsequent phases + +--- + +#### Phase 3: Sidebar Redesign + +**Goal:** Transform the 56dp icon-only sidebar into a 240dp labeled navigation with avatar, custom feeds, and animated collapse. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../ui/deck/DeckSidebar.kt` | Major rewrite — wide layout, labels, sections | +| `desktopApp/.../ui/account/AccountSwitcherDropdown.kt` | Move avatar to sidebar top, restyle | +| `desktopApp/.../ui/deck/DeckLayout.kt` | Adjust sidebar width to use animated state | + +**Current sidebar structure (DeckSidebar.kt):** +- 56.dp wide Column +- AccountSwitcherDropdown (person icon) → Add Column → Import → Spacer → Bunker/Tor → Settings +- No labels, no active state indicator, no feeds section + +**New sidebar structure:** + +``` +┌─────────────────────────┐ +│ [Avatar 40dp] Username │ ← tappable, opens account switcher +│ @npub... │ +├─────────────────────────┤ +│ 🏠 Home │ ← main nav items +│ 🔍 Search │ +│ ✉️ Messages │ +│ 💰 Wallet │ +│ 🔖 Bookmarks │ +│ ⚙️ Settings │ +├─────────────────────────┤ +│ FEEDS │ ← section header +│ 📷 Photography │ ← from FeedDefinitionRepository +│ 🏡 Homestead │ +│ 🏛️ Architecture │ +├─────────────────────────┤ +│ │ ← spacer +│ [Bunker] [Tor] │ ← status indicators +│ ◀ Collapse │ ← collapse toggle +└─────────────────────────┘ +``` + +**Key implementation details:** + +1. **Animated width:** `animateDpAsState(if (expanded) 240.dp else 56.dp, tween(300, easing = FastOutSlowInEasing))` + `Modifier.clipToBounds()` on sidebar container +2. **Column width debounce:** DeckLayout's `fitColumnsToWidth()` fires via `LaunchedEffect(availableWidthDp)`. During sidebar animation, this thrashes every frame. Add `snapshotFlow { availableWidthDp }.debounce(300)` or gate on animation completion. +3. **Collapse persistence:** `java.util.prefs.Preferences` key `sidebar_collapsed` +3. **Nav items:** Map `DeckColumnType` to sidebar entries with icon + label +4. **Active state:** `primaryContainer` background pill with `primary` tinted icon + `SemiBold` text +5. **Hover state:** `onSurface.copy(alpha = 0.08f)` background on hover (see Phase 6 for shared utility) +6. **Feeds section:** Read from `LocalFeedRepository.current` → `groupedFeeds.pinned + myFeeds` +7. **Feed icons:** Each `FeedDefinition` gets an icon field (Material Symbol codepoint). Default to a generic feed icon. +8. **Avatar:** 40dp circular, loaded from `LocalCache` user metadata. Fallback to `Person` icon. +9. **Account switcher:** Tap avatar opens existing `AccountSwitcherDropdown` (restyled with rounded corners). **Fix dropdown offset:** current hardcoded `DpOffset(x = 48.dp)` must become dynamic based on sidebar width. +10. **Collapsed mode:** Only icons shown, no labels, tooltip on hover. Width animates to 56dp. **Accessibility:** Add `contentDescription` to icons when labels are hidden. +11. **Keyboard shortcut:** `Cmd/Ctrl+B` to toggle sidebar collapse (add to MenuBar in Main.kt). + +**Label fade animation:** +```kotlin +AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), +) { + Text(label, style = MaterialTheme.typography.labelLarge) +} +``` + +**Success criteria:** +- [ ] Sidebar shows 240dp with avatar + nav items + feeds +- [ ] Active item has cyan pill indicator +- [ ] Collapse toggle animates smoothly +- [ ] Collapse state persists across restarts +- [ ] Custom feeds appear in FEEDS section +- [ ] Account switcher works from avatar tap + +--- + +#### Phase 4: Card & Action Bar Refinement + +**Goal:** Restyle NoteCard to flat+border, update action bar to match screenshot, add image corner treatment. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../ui/note/NoteCard.kt` | Flat + border, 16dp padding, action bar, image corners | + +**Card changes:** + +> **Research fix:** `BorderStroke` creates new instance each recomposition, defeating skip. Remember it. NoteCard must accept `modifier` parameter. + +```kotlin +@Composable +fun NoteCard( + // ... other params, + modifier: Modifier = Modifier, +) { + val border = remember(MaterialTheme.colorScheme.outlineVariant) { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + } + OutlinedCard( + modifier = modifier, + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = border, + shape = MaterialTheme.shapes.medium, // 12dp + ) { + Column(modifier = Modifier.padding(MaterialTheme.spacing.cardPadding)) { ... } + } +} +``` + +> **Scope note:** 10+ files use `CardDefaults.cardColors` across desktop (NotificationsScreen, ChessScreen, ProfileInfoCard, WalletColumnScreen, etc.). All need consistent conversion to OutlinedCard. + +**Action bar redesign:** +- Icon size: 20dp (down from current) +- Layout: `Row` with `Arrangement.spacedBy(16.dp)` +- Each action: Icon + count `Text(labelSmall)` in a `Row(spacing = 4.dp)` +- Active states: filled icon + `primary` color (liked heart, zapped lightning, reposted) +- Icons: comment, heart, zap (lightning bolt), repost, share + +**Image treatment:** +- All inline images: `clip(RoundedCornerShape(8.dp))` +- Images get `padding(top = 8.dp)` for spacing from text above +- Already partially implemented (8.dp corners exist) — ensure consistency + +**Card gaps:** +- Remove any `HorizontalDivider` between cards in feed lists +- Use `Arrangement.spacedBy(MaterialTheme.spacing.cardGap)` (8dp) in `LazyColumn` + +**Success criteria:** +- [ ] Cards show flat white surface with subtle border +- [ ] 16dp internal padding +- [ ] Action bar uses smaller icons with count badges +- [ ] Images have rounded corners +- [ ] No divider lines between cards + +--- + +#### Phase 5: Column Headers & Search + +**Goal:** Restyle per-column headers to match screenshot aesthetic, add rounded pill search bar, segmented feed toggles. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../ui/deck/ColumnHeader.kt` | Restyle: height, background, typography | +| `desktopApp/.../ui/deck/FeedScreen.kt` (or equivalent) | Add SegmentedButton for My Feed / Global toggle | +| Search composable | Rounded pill styling | + +**ColumnHeader changes:** +```kotlin +// Before: surfaceVariant.copy(alpha = 0.5f), height = 40.dp +// After: +Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.height(MaterialTheme.spacing.columnHeaderHeight) // 48dp +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // icon + title with titleMedium style + } +} +``` + +**Search bar (rounded pill):** +```kotlin +TextField( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ), + leadingIcon = { Icon(Search) }, + trailingIcon = { Text("⌘K", style = labelSmall, color = onSurfaceVariant) }, + placeholder = { Text("Search notes, profiles, hashtags...") }, +) +``` + +**Feed toggles (SegmentedButton):** +```kotlin +SingleChoiceSegmentedButtonRow { + SegmentedButton(selected = isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { + Text("My Feed") + } + SegmentedButton(selected = !isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { + Text("Global") + } +} +``` + +> **Research insight:** SegmentedButton confirmed available and stable in CMP 1.10.3 (already used in AppDrawer.kt). At `MIN_COLUMN_WIDTH` (300dp), SegmentedButton + icon + close button is tight. Gate visibility on column width > 400dp. + +> **Keyboard shortcut:** Wire `Cmd/Ctrl+K` to focus the search column's text field in MenuBar (Main.kt). + +**Success criteria:** +- [ ] Column headers have consistent 48dp height with surfaceContainer background +- [ ] Search bar is rounded pill with keyboard shortcut hint (Cmd+K wired) +- [ ] Feed toggle uses M3 SegmentedButton (hidden in narrow columns < 400dp) + +--- + +#### Phase 6: Polish & Micro-interactions + +**Goal:** Add hover effects, skeleton loading, styled tooltips/dialogs/context menus/scrollbars/snackbars. + +**Files:** + +| File | Action | +|------|--------| +| New: `desktopApp/.../ui/theme/HoverModifiers.kt` | `Modifier.hoverHighlight()` (desktop-only, `onPointerEvent`) | +| New: `commons/.../ui/components/ShimmerPlaceholder.kt` | Skeleton shimmer (pure M3, shared) | +| `desktopApp/.../ui/note/NoteCard.kt` | Add hover effect | +| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add hover to nav items | +| Dialog composables | Surface color in dark mode | +| DropdownMenu usages | Styled corners + border | + +**Hover utility (uses `onPointerEvent` — codebase convention from AppDrawer.kt, ChatPane.kt):** + +> **Research fix:** Original plan used `composed{}` + `@Composable` lambda in `drawBehind` — won't compile. `shape.topStart` is invalid on generic `Shape`. Rewritten to match project patterns. + +```kotlin +// HoverModifiers.kt (desktopApp/jvmMain — desktop-only, uses ExperimentalComposeUiApi) +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun Modifier.hoverHighlight( + hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + shape: Shape = MaterialTheme.shapes.medium, +): Modifier { + val color = remember { mutableStateOf(Color.Transparent) } + return this + .onPointerEvent(PointerEventType.Enter) { color.value = hoverColor } + .onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent } + .drawBehind { drawRect(color.value) } + // drawBehind reads color.value in draw phase only — no recomposition on hover +} +``` + +> **Performance note:** State read deferred to draw phase via `drawBehind`. No recomposition on hover enter/exit — only draw invalidation. Safe for LazyColumn with 50+ cards. + +**Skeleton shimmer:** +```kotlin +// ShimmerPlaceholder.kt +@Composable +fun ShimmerPlaceholder(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition() + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)), + ) + val brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + ), + start = Offset(translateAnim - 500f, 0f), + end = Offset(translateAnim, 0f), + ) + Box(modifier.background(brush, MaterialTheme.shapes.medium)) +} + +@Composable +fun NoteCardSkeleton() { + OutlinedCard( + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape)) // avatar + Column(Arrangement.spacedBy(4.dp)) { + ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp)) // name + ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp)) // timestamp + } + } + ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp)) // text line 1 + ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp)) // text line 2 + ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp)) // image + } + } +} +``` + +**Dialog styling (dark mode):** + +> **Research fix:** `ColorScheme.isLight` does not exist in M3. Use `LocalIsDarkTheme` from Phase 2. + +```kotlin +// Wrap existing Dialog composables +Dialog(onDismissRequest = ...) { + Surface( + color = if (LocalIsDarkTheme.current) + MaterialTheme.colorScheme.surfaceContainerHighest + else + MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.large, // 16dp + tonalElevation = 6.dp, + ) { ... } +} +``` + +**Styled context menus:** +```kotlin +DropdownMenu( + modifier = Modifier + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.medium) + .clip(MaterialTheme.shapes.medium), + ... +) +``` + +**Styled tooltips:** +```kotlin +TooltipBox( + tooltip = { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = MaterialTheme.shapes.small, + shadowElevation = 2.dp, + ) { + Text(text, modifier = Modifier.padding(8.dp, 4.dp), style = labelSmall) + } + }, + ... +) +``` + +**Snackbar (extract shared composable — two SnackbarHost instances exist: Main.kt and ChatPane.kt):** +```kotlin +// commons/commonMain — pure M3, no platform APIs +@Composable +fun AmethystSnackbarHost( + hostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + SnackbarHost(hostState, modifier) { data -> + Snackbar( + snackbarData = data, + shape = MaterialTheme.shapes.medium, + containerColor = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + actionColor = MaterialTheme.colorScheme.primary, + ) + } +} +``` + +> **Research insight:** Plan originally used `surfaceContainerHighest` — may lack WCAG AA contrast in light mode. `inverseSurface`/`inverseOnSurface` provides high contrast in both modes (M3 standard snackbar pattern). + +**Scrollbar styling (via built-in `LocalScrollbarStyle`):** +```kotlin +// Provide at theme root in PlatformMaterialTheme +CompositionLocalProvider( + LocalScrollbarStyle provides ScrollbarStyle( + minimalHeight = 48.dp, thickness = 4.dp, + shape = RoundedCornerShape(2.dp), hoverDurationMillis = 300, + unhoverColor = Color.Black.copy(alpha = 0.12f), + hoverColor = Color.Black.copy(alpha = 0.50f), + ) +) { ... } +``` + +**Success criteria:** +- [ ] Cards highlight on hover +- [ ] Sidebar items highlight on hover +- [ ] Skeleton shimmer shown during feed loading +- [ ] Dialogs use elevated surface in dark mode +- [ ] Context menus have rounded corners + border +- [ ] Tooltips are styled +- [ ] Snackbar matches brand + +--- + +## System-Wide Impact + +### Interaction Graph + +Theme changes cascade through `PlatformMaterialTheme` → all `MaterialTheme.colorScheme.*` / `.shapes.*` / `.typography.*` consumers. No callbacks or middleware — purely declarative recomposition. + +### Error Propagation + +No new error paths. Theme resolution is pure function (no IO). Sidebar collapse preference uses `java.util.prefs.Preferences` (already used for custom feeds — failure falls back to expanded). + +### State Lifecycle Risks + +- Sidebar collapse state: stored in `Preferences`, read once at composition. No partial state risk. +- Feed definitions: already managed by `FeedDefinitionRepository` with `StateFlow`. Sidebar reads same flow. +- Account avatar: loaded from `LocalCache` metadata — may be null initially (fallback to icon). + +### API Surface Parity + +No external API changes. All changes are internal UI. + +### Integration Test Scenarios + +1. **Dark/light toggle:** Switch modes mid-session → all colors update (no cached old scheme) +2. **Sidebar collapse + window resize:** Collapse sidebar, resize window narrow → columns should not clip +3. **Custom feed CRUD:** Create/delete feed → sidebar FEEDS section updates live +4. **Account switch:** Switch account → avatar + username in sidebar update +5. **Feed loading:** Navigate to a feed → skeleton shimmer shows → cards render + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Unified Amethyst color scheme (cyan/blue accent) on all platforms +- [ ] Dark/light mode fully functional with proper contrast +- [ ] 240dp sidebar with icon + label navigation items +- [ ] Sidebar collapse/expand with smooth animation +- [ ] Collapse state persists across app restarts +- [ ] Avatar + username at top of sidebar with account switcher +- [ ] Custom feeds section in sidebar reading from FeedDefinitionRepository +- [ ] Flat cards with subtle border (no shadow) +- [ ] 16dp card padding, 12dp card corners +- [ ] Action bar with smaller icons and count badges +- [ ] Rounded images with 8dp corners +- [ ] Per-column headers with consistent 48dp height +- [ ] Rounded pill search bar with keyboard shortcut hint +- [ ] M3 SegmentedButton for feed toggles +- [ ] Hover effects on cards and sidebar items +- [ ] Skeleton shimmer during feed loading +- [ ] Styled tooltips, context menus, dialogs, snackbars + +### Non-Functional Requirements + +- [ ] App compiles and runs on macOS, Windows, Linux +- [ ] No regression in existing functionality +- [ ] Font rendering quality maintained (per-OS fonts preserved) +- [ ] Smooth animations (sidebar collapse, hover) at 60fps + +### Quality Gates + +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew spotlessApply` clean +- [ ] Manual visual QA in dark and light modes +- [ ] Sidebar collapse/expand tested + +## Dependencies & Prerequisites + +- No external library additions — everything uses existing Material3 + Compose APIs +- `FeedDefinitionRepository` and custom feeds infrastructure already exist +- `AccountSwitcherDropdown` already exists — needs restyling, not rewriting +- Material Symbols font subset may need new icons for feed types (run `./tools/material-symbols-subset/subset.sh`) + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|-----------| +| Color contrast issues in dark mode | Medium | Medium | Test with accessibility contrast checker | +| Sidebar animation jank on Linux | Low | Low | Use `tween(250)` easing, test on GNOME | +| Breaking existing hover behavior | Low | Medium | Audit all `pointerInput` / `onPointerEvent` usages first | +| Missing Material Symbol icons | Medium | Low | Run subset script, add needed codepoints | +| Inline Color() literals bypassing theme | Medium | Low | Grep audit + replace in Phase 1 | + +## Migration Checklist + +- [ ] Remove per-OS color scheme functions (macOsLight, gnomeDark, etc.) +- [ ] Remove `PlatformAccent` accent resolution (no longer needed) +- [ ] Remove per-OS shape variants +- [ ] Grep for `Color(0xFF` in `desktopApp/` — replace with theme references +- [ ] Remove `darkenForLight()` helper +- [ ] Update any tests referencing old color values + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md](docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md) — Key decisions: cyan/blue accent (#0096FF), flat+border cards, 240dp collapsible sidebar, Amethyst-branded over OS-native + +### Internal References + +- `PlatformColorScheme.kt` — current per-OS scheme implementation +- `PlatformTheme.kt` — theme composition entry point +- `DeckSidebar.kt` — current 56dp icon sidebar +- `NoteCard.kt` — current card styling +- `ColumnHeader.kt` — current column header (40dp, surfaceVariant) +- `AccountSwitcherDropdown.kt` — existing account switcher +- `FeedDefinitionRepository.kt` — custom feed data source +- `FeedsDrawerTab.kt` — current feed browsing UI + +### External References + +- [Material Design 3 Color System](https://m3.material.io/styles/color/system/overview) +- [Material Design 3 Elevation](https://m3.material.io/styles/elevation/applying-elevation) +- [Custom Design Systems in Compose](https://developer.android.com/develop/ui/compose/designsystems/custom) diff --git a/docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md b/docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md new file mode 100644 index 0000000000..f23cb69076 --- /dev/null +++ b/docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md @@ -0,0 +1,175 @@ +--- +title: "fix: Feed Header Bar Layout + Inline Search UX" +type: fix +status: active +date: 2026-05-28 +origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md +--- + +# fix: Feed Header Bar Layout + Inline Search UX + +## Overview + +Fix 5 issues with the feed header bar and search experience introduced in the visual personality overhaul. + +## Problems + +| # | Issue | Root Cause | +|---|-------|-----------| +| 1 | Feed tabs take too much space, search not centered | `Row` with `SpaceBetween` pushes search to far right. Tabs + "+ More" consume all left space | +| 2 | Clicking search pill doesn't open search | `onSearchClick` callback not wired from `DeckColumnContainer` → `FeedScreen` → `FeedTabsHeader` | +| 3 | Search opens as Dialog overlay (wrong UX) | `SearchSpotlight` uses `Dialog()` which creates separate AWT window. Should be inline expansion of the pill with dropdown + background blur | +| 4 | Follow tabs can't be clicked | FilterChip `onClick` calls `onNavigateToFeed` for custom feeds, but the callback only handles `FeedSource.Filter` — other source types silently ignored | +| 5 | Header doesn't match screenshot card aesthetic | Header is a plain `Row` with padding. Should be a card-like `Surface` with rounded corners and visual separation | + +## Proposed Solution + +### Fix 1: Redesign FeedTabsHeader layout + +**File:** `FeedScreen.kt` — `FeedTabsHeader` + +New layout — compact tabs on left, search pill takes center weight, compose on right: +``` +┌─────────────────────────────────────────────────────┐ +│ [Following][Global][+] 🔍 Search notes... ⌘F ✏️ │ +└─────────────────────────────────────────────────────┘ +``` + +- Feed tabs: compact `FilterChip` with just emoji+name, `Arrangement.spacedBy(4.dp)` +- Remove "+ More" chip (moved to sidebar already) +- SearchPill: `Modifier.weight(1f)` so it fills remaining center space +- Compose button: stays on far right +- Wrap entire header in `Surface(shape = shapes.medium, color = surfaceContainer)` with 12dp padding + +### Fix 2: Wire onSearchClick callback + +**File:** `DeckColumnContainer.kt` + +Pass `onSearchClick = { showSearchSpotlight = true }` through `FeedScreen` call sites. The `showSearchSpotlight` state is already at Main.kt level — need to pass it down as callback. + +Simplest approach: add `onSearchClick` param to `MainContent` → column container → FeedScreen. + +### Fix 3: Replace Dialog with inline search expansion + +**File:** `SearchSpotlight.kt` → Delete. Replace with inline expansion in `SearchPill.kt` + +Instead of a Dialog overlay, the SearchPill itself expands: + +**Collapsed (default):** +``` +🔍 Search notes, profiles... ⌘F +``` + +**Expanded (on click or Cmd+F):** +``` +┌────────────────────────────────────────┐ +│ 🔍 [typing here...] ⌘F │ +├────────────────────────────────────────┤ +│ Recent │ +│ 📝 bitcoin lightning │ +│ 📝 @fiatjaf │ +├────────────────────────────────────────┤ +│ Saved │ +│ ⭐ Nostr development │ +└────────────────────────────────────────┘ ++ background blur/dim behind dropdown +``` + +Implementation: +- `SearchPill` gains `expanded: Boolean` state +- When expanded: pill becomes `BasicTextField` with same shape, a `DropdownMenu` or `Popup` appears below with history/results +- Background: `Box(Modifier.fillMaxSize().background(Color.Black.copy(0.3f)))` rendered at the parent level when expanded +- Clicking outside or pressing Escape collapses +- `Cmd+F` sets expanded = true on the active feed column's SearchPill + +### Fix 4: Fix follow tab click handlers + +**File:** `FeedScreen.kt` — `FeedTabsHeader` + +Current `onNavigateToFeed` callback only handles `FeedSource.Filter`. Need to also handle `FeedSource.Following` and `FeedSource.Global` by calling `onFeedModeChange` directly in the chip onClick (already done for the `when` branches — the bug is that custom feed chips with non-Filter sources silently do nothing). + +Check: is `feed.source` ever something other than `Following`, `Global`, or `Filter`? If so, add handling. + +### Fix 5: Card-based header design + +**File:** `FeedScreen.kt` — `FeedTabsHeader` + +Wrap the header Row in a `Surface`: +```kotlin +Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp), +) { + Row( + modifier = Modifier.padding(8.dp), + ... + ) { ... } +} +``` + +This gives it the same card treatment as feed items — white surface, subtle border, rounded corners. + +## Implementation Phases + +### Phase 1: Fix header layout + card design (Fixes 1, 5) + +**FeedScreen.kt — rewrite FeedTabsHeader:** +- Wrap in `Surface` with `OutlinedCard` styling +- Compact tabs (remove "+ More") +- SearchPill with `weight(1f)` in center +- 48dp height, consistent padding + +### Phase 2: Inline search expansion (Fixes 2, 3) + +**Delete SearchSpotlight.kt** — replace with expanded state in SearchPill. + +**New SearchPill.kt:** +```kotlin +@Composable +fun SearchPill( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onOpenFullSearch: (String) -> Unit, + modifier: Modifier = Modifier, +) +``` +- Collapsed: clickable Surface pill (current design) +- Expanded: `BasicTextField` in same pill shape + `Popup`/`DropdownMenu` below with history +- Scrim: parent renders a semi-transparent overlay when `expanded = true` + +**Main.kt:** +- `Cmd+F` sets a `searchExpanded` state that's passed down to the active feed +- Remove `SearchSpotlight` rendering + +### Phase 3: Fix tab click handlers (Fix 4) + +**FeedScreen.kt:** Audit `onNavigateToFeed` callback path. Ensure all `FeedSource` types are handled. + +## Acceptance Criteria + +- [ ] Feed tabs are compact, search pill fills remaining center space +- [ ] Header wrapped in card-like Surface matching screenshot aesthetic +- [ ] Clicking SearchPill expands it into an input with dropdown below +- [ ] Cmd+F expands the search pill (no separate overlay) +- [ ] Typing in expanded search shows recent + saved searches +- [ ] Clicking outside or pressing Escape collapses search +- [ ] All feed tabs (Following, Global, custom) are clickable and switch feeds +- [ ] "+ More" removed from header (already in sidebar) +- [ ] Compiles, spotless clean + +## Files Affected + +| File | Action | +|------|--------| +| `desktopApp/.../ui/FeedScreen.kt` | Rewrite `FeedTabsHeader` — layout, card design, tab fix | +| `desktopApp/.../ui/search/SearchPill.kt` | Add expanded state, inline BasicTextField, dropdown | +| `desktopApp/.../ui/search/SearchSpotlight.kt` | Delete (replaced by inline expansion) | +| `desktopApp/.../Main.kt` | Remove SearchSpotlight rendering, wire Cmd+F to feed search expansion | + +## Sources + +- Brainstorm: `docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md` +- Deepen research: Dialog creates separate AWT window on desktop — use Box overlay or inline instead +- Existing pattern: `LightboxOverlay.kt` uses Box overlay (not Dialog) diff --git a/todos/001-pending-p2-feed-content-margin-under-search.md b/todos/001-pending-p2-feed-content-margin-under-search.md new file mode 100644 index 0000000000..206d10efb9 --- /dev/null +++ b/todos/001-pending-p2-feed-content-margin-under-search.md @@ -0,0 +1,34 @@ +--- +status: pending +priority: p2 +issue_id: "001" +tags: [code-review, ui, desktop, search] +--- + +# Feed content needs more margin below search bar + +## Problem Statement + +When the search bar is expanded, feed items below get partially hidden by the expanded search card. The spacer height (60dp) that reserves space for the header is not enough when the search card expands with history/results. + +## Findings + +- FeedScreen.kt line 587: `Spacer(Modifier.height(60.dp))` reserves space for the collapsed header only +- When search expands, the card grows downward but the feed content doesn't shift +- Feed items near the top get obscured by the expanded search card + scrim + +## Proposed Solutions + +**Option A: Dynamic spacer based on search state** +- When `searchActive`, increase spacer to match expanded card height (~300dp) +- Pros: exact spacing. Cons: needs to track card height dynamically. + +**Option B: Add extra bottom padding to expanded card** +- Feed items already have their own padding. Just ensure the search card's expanded area doesn't overlap. +- Pros: simple. Cons: may not cover all cases. + +## Acceptance Criteria + +- [ ] Feed items below the search bar are fully visible when search is collapsed +- [ ] When search expands, feed content is not obscured by the expanded card +- [ ] Smooth transition when expanding/collapsing diff --git a/todos/002-pending-p2-search-history-item-click-populates-input.md b/todos/002-pending-p2-search-history-item-click-populates-input.md new file mode 100644 index 0000000000..84b8a99ac3 --- /dev/null +++ b/todos/002-pending-p2-search-history-item-click-populates-input.md @@ -0,0 +1,34 @@ +--- +status: pending +priority: p2 +issue_id: "002" +tags: [code-review, ui, desktop, search] +--- + +# Tapping search history should populate input and search + +## Problem Statement + +Clicking a recent search item in the expanded search history currently just calls `onOpenFullSearch()` (opens full Search column). It should instead populate the search input with that query text and immediately start searching. + +## Findings + +- FeedScreen.kt SearchHistorySection: all history rows call `onOpenFullSearch()` on click +- Should instead: set `searchText` to the history item's text, which triggers `updateFromText()` and relay subscriptions + +## Proposed Solutions + +**Option A: Pass `onHistoryItemClick: (String) -> Unit` to SearchHistorySection** +- Callback sets `searchText = TextFieldValue(text)` in parent +- `LaunchedEffect(searchText.text)` triggers `updateFromText()` automatically +- Pros: clean separation. Cons: needs callback threading. + +**Option B: Pass `searchText` MutableState directly** +- SearchHistorySection writes to the state directly +- Pros: simple. Cons: tight coupling. + +## Acceptance Criteria + +- [ ] Clicking a recent search item populates the search input with that text +- [ ] Search results start loading immediately after populating +- [ ] The search bar stays expanded (doesn't collapse) diff --git a/todos/003-pending-p2-cmd-f-context-aware.md b/todos/003-pending-p2-cmd-f-context-aware.md new file mode 100644 index 0000000000..6e743ea101 --- /dev/null +++ b/todos/003-pending-p2-cmd-f-context-aware.md @@ -0,0 +1,35 @@ +--- +status: pending +priority: p2 +issue_id: "003" +tags: [code-review, ui, desktop, search, keyboard] +--- + +# Cmd+F should be context-aware: inline on feeds, full search elsewhere + +## Problem Statement + +Cmd+F currently toggles `feedSearchActiveState` which only works on the Home/Feeds screen. When on Messages, Settings, Bookmarks, or other non-feed screens, Cmd+F should open the full Search column instead. + +## Findings + +- Main.kt MenuBar: Cmd+F sets `feedSearchActiveState.value = !feedSearchActiveState.value` +- FeedScreen reads `LocalFeedSearchActive.current` — only works when FeedScreen is visible +- On other screens (Messages, Settings, etc.), the state changes but nothing happens visually + +## Proposed Solutions + +**Option A: Check current screen type in Cmd+F handler** +- Read `activeColumnType` from deck/single-pane state +- If HomeFeed/GlobalFeed/CustomFeed → toggle inline search +- Otherwise → navigate to Search column + +**Option B: Use two shortcuts** +- Cmd+F → always opens inline search on feed (no-op on other screens) +- Cmd+Shift+F → always opens full Search column + +## Acceptance Criteria + +- [ ] Cmd+F on Home/Feeds screen → inline search expands +- [ ] Cmd+F on any other screen → opens full Search column/screen +- [ ] Cmd+F on already-expanded search → collapses it diff --git a/todos/004-pending-p3-hardcoded-status-colors.md b/todos/004-pending-p3-hardcoded-status-colors.md new file mode 100644 index 0000000000..8e3cf3cdff --- /dev/null +++ b/todos/004-pending-p3-hardcoded-status-colors.md @@ -0,0 +1,36 @@ +--- +status: pending +priority: p3 +issue_id: "004" +tags: [code-review, theming, desktop] +--- + +# Hardcoded status colors should use theme tokens + +## Problem Statement + +32 inline Color() constructors across 10 desktop files use hardcoded RGB values for status indicators (green/red/amber). These don't adapt to dark/light mode properly. + +## Findings + +Key files: RelayStatusCard.kt, TorStatusIndicator.kt, LoginProgressSteps.kt, MediaServerSettings.kt, DevSettingsSection.kt, NewKeyWarningCard.kt, ProfileInfoCard.kt + +Common hardcoded values: +- `Color(0xFF4CAF50)` green — should use a semantic success color +- `Color(0xFFF44336)` red — should use `MaterialTheme.colorScheme.error` +- `Color(0xFFFFB300)` amber — should use a semantic warning color +- `Color.Red` / `Color.Green` — bare Material colors + +## Proposed Solutions + +Extract semantic status colors as ColorScheme extensions: +```kotlin +val ColorScheme.statusSuccess: Color get() = if (isLight) Color(0xFF339900) else Color(0xFF99cc33) +val ColorScheme.statusError: Color get() = error +val ColorScheme.statusWarning: Color get() = if (isLight) Color(0xFFC09B14) else Color(0xFFE1C419) +``` + +## Acceptance Criteria + +- [ ] No bare Color.Red/Green/Yellow in desktop UI files +- [ ] Status colors adapt properly to dark/light mode diff --git a/todos/005-pending-p3-inline-shape-audit.md b/todos/005-pending-p3-inline-shape-audit.md new file mode 100644 index 0000000000..7ebabde682 --- /dev/null +++ b/todos/005-pending-p3-inline-shape-audit.md @@ -0,0 +1,26 @@ +--- +status: pending +priority: p3 +issue_id: "005" +tags: [code-review, theming, desktop] +--- + +# ~40 inline RoundedCornerShape() calls should use MaterialTheme.shapes + +## Problem Statement + +The desktopApp module has ~40 inline `RoundedCornerShape()` calls with values 4dp, 8dp, 10dp, 12dp, 16dp. These should map to `MaterialTheme.shapes.*` tokens for consistency with the unified Amethyst shape system. + +## Findings + +Common clusters: +- `8.dp` (~20 occurrences) → `MaterialTheme.shapes.small` +- `12.dp` (~5 occurrences) → `MaterialTheme.shapes.medium` +- `16.dp` (~4 occurrences) → `MaterialTheme.shapes.large` +- `100.dp` / `999.dp` → pill shapes (acceptable as-is) + +## Acceptance Criteria + +- [ ] All 8dp corner shapes use MaterialTheme.shapes.small +- [ ] All 12dp corner shapes use MaterialTheme.shapes.medium +- [ ] All 16dp corner shapes use MaterialTheme.shapes.large From 12918e7045c92c1d6b0e8f25325ceabb1e363a6d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 28 May 2026 13:37:10 +0300 Subject: [PATCH 40/51] feat(desktop): Tor/Bunker status as proper sidebar nav items - Replace compact icon Row at bottom with SidebarNavItem-style items - Tor: shows "Tor: Off/Connecting/Connected/Error" with Shield icon - Bunker: shows "Bunker: OK" with Favorite icon (only when connected) - Both use same shape, hover, and label pattern as other sidebar items - Collapsed mode: icon-only with tooltip, same as nav items Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index a33c5ecaad..b263d494f7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -28,7 +28,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut 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 import androidx.compose.foundation.layout.Row @@ -72,12 +71,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus -import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop -import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.collections.immutable.ImmutableList @@ -245,21 +242,35 @@ fun MainSidebar( } } - // -- Bottom section: indicators + collapse toggle -- + // -- Bottom section: status indicators + collapse toggle -- HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) Spacer(Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, + // Tor status — same style as nav items + val torLabel = + when (torStatus) { + is TorServiceStatus.Off -> "Tor: Off" + is TorServiceStatus.Connecting -> "Tor: Connecting" + is TorServiceStatus.Active -> "Tor: Connected" + is TorServiceStatus.Error -> "Tor: Error" + } + SidebarNavItem( + icon = MaterialSymbols.Shield, + label = torLabel, + isActive = false, + expanded = expanded, + onClick = onOpenSettings, + ) + + // Bunker heartbeat — same style as nav items (only show when connected) + if (signerConnectionState is SignerConnectionState.Connected) { + SidebarNavItem( + icon = MaterialSymbols.Favorite, + label = "Bunker: OK", + isActive = false, + expanded = expanded, + onClick = onOpenSettings, ) - Spacer(Modifier.width(4.dp)) - TorStatusIndicator(status = torStatus, onClick = onOpenSettings) } Spacer(Modifier.height(4.dp)) From da0aaaa281fd835f7da382468b528d11d338c3dc Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 29 May 2026 06:18:13 +0300 Subject: [PATCH 41/51] fix(desktop): wider collapsed sidebar + filled Tor icon when connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COLLAPSED_WIDTH 56dp → 64dp so icons aren't truncated - Tor connected: use Security icon (filled shield) instead of Shield - Tor off/connecting/error: keep outlined Shield icon Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index b263d494f7..acec9834cf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -79,7 +79,7 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.collections.immutable.ImmutableList private val EXPANDED_WIDTH = 240.dp -private val COLLAPSED_WIDTH = 56.dp +private val COLLAPSED_WIDTH = 64.dp /** * Data class for a sidebar navigation item. @@ -254,8 +254,10 @@ fun MainSidebar( is TorServiceStatus.Active -> "Tor: Connected" is TorServiceStatus.Error -> "Tor: Error" } + val torIcon = + if (torStatus is TorServiceStatus.Active) MaterialSymbols.Security else MaterialSymbols.Shield SidebarNavItem( - icon = MaterialSymbols.Shield, + icon = torIcon, label = torLabel, isActive = false, expanded = expanded, From 0e431d674c6cc39b97c4dd98df9720521a0522c8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 29 May 2026 06:37:09 +0300 Subject: [PATCH 42/51] feat(desktop): branded loading screen instead of login flash on startup - Add AccountState.Loading as initial state (was LoggedOut) - Show centered "Amethyst" + spinner while accounts load from storage - After loadSavedAccount(): transition to LoggedIn or LoggedOut - No more 0.5s flash of login screen when account exists Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 28 ++++++++++++++++++- .../desktop/account/AccountManager.kt | 8 +++++- .../AccountManagerStateTransitionTest.kt | 8 +++--- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index b531a2a0d4..d1548c194e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -864,6 +865,7 @@ fun App( } is AccountState.ConnectingRelays -> {} + is AccountState.Loading -> {} } } @@ -924,8 +926,10 @@ fun App( if (current != null) { accountManager.loadNwcConnection(current.npub) } + } else { + // No saved account found → show login screen + accountManager.setLoggedOut() } - // If failure: state remains LoggedOut → login screen shows automatically } onDispose { @@ -956,6 +960,28 @@ fun App( com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore, ) { when (accountState) { + is AccountState.Loading -> { + // Branded loading screen while accounts load from storage + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + Spacer(Modifier.height(16.dp)) + Text( + "Amethyst", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } + is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 9b580b467a..4c4ef4ebc8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -69,6 +69,8 @@ import kotlinx.coroutines.withTimeout import java.io.File sealed class AccountState { + data object Loading : AccountState() + data object LoggedOut : AccountState() data object ConnectingRelays : AccountState() @@ -123,7 +125,7 @@ class AccountManager internal constructor( private val _allAccounts = MutableStateFlow>(persistentListOf()) val allAccounts: StateFlow> = _allAccounts.asStateFlow() - private val _accountState = MutableStateFlow(AccountState.LoggedOut) + private val _accountState = MutableStateFlow(AccountState.Loading) val accountState: StateFlow = _accountState.asStateFlow() private val _nwcConnection = MutableStateFlow(null) @@ -463,6 +465,10 @@ class AccountManager internal constructor( accountStorage.setCurrentAccount(npub) } + fun setLoggedOut() { + _accountState.value = AccountState.LoggedOut + } + fun setConnectingRelays() { _accountState.value = AccountState.ConnectingRelays } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt index f9adf3bff9..6734d49793 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt @@ -94,12 +94,12 @@ class AccountManagerStateTransitionTest { advanceUntilIdle() - // Should see: LoggedOut → ConnectingRelays → LoggedIn + // Should see: Loading → ConnectingRelays → LoggedIn assertTrue( states.size >= 3, "Expected at least 3 state transitions, got ${states.size}: $states", ) - assertIs(states[0]) + assertIs(states[0]) assertIs(states[1]) assertIs(states[2]) @@ -125,12 +125,12 @@ class AccountManagerStateTransitionTest { advanceUntilIdle() - // Should see: LoggedOut → ConnectingRelays → LoggedOut + // Should see: Loading → ConnectingRelays → LoggedOut assertTrue( states.size >= 3, "Expected at least 3 state transitions, got ${states.size}: $states", ) - assertIs(states[0]) + assertIs(states[0]) assertIs(states[1]) assertIs(states[2]) From cc698c5e911b001d74dcc3c30f31427efb0fc718 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 29 May 2026 06:52:44 +0300 Subject: [PATCH 43/51] fix(desktop): sidebar starts expanded + extra 16dp feed margin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar always starts expanded (ignores persisted collapsed state) - Feed spacer 60dp → 76dp (extra 16dp gap between header card and first item) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 2 +- .../com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 332cd84430..f66fb1176e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -608,7 +608,7 @@ fun FeedScreen( // Reserve space for the header card that floats above. // When search is expanded, the card grows — add more margin. val headerSpacerHeight by animateDpAsState( - targetValue = if (searchActive) 300.dp else 60.dp, + targetValue = if (searchActive) 300.dp else 76.dp, animationSpec = tween(200), ) Spacer(Modifier.height(headerSpacerHeight)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index acec9834cf..fa0ba33380 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -119,7 +119,7 @@ fun MainSidebar( torStatus: TorServiceStatus, modifier: Modifier = Modifier, ) { - var expanded by remember { mutableStateOf(!DesktopPreferences.sidebarCollapsed) } + var expanded by remember { mutableStateOf(true) } val animatedWidth by animateDpAsState( targetValue = if (expanded) EXPANDED_WIDTH else COLLAPSED_WIDTH, From 71a38674b0a1787185e4d29b0a75854d38666867 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 29 May 2026 06:56:27 +0300 Subject: [PATCH 44/51] fix(desktop): first feed item 16dp top padding via LazyColumn contentPadding - Revert spacer change (back to 60dp) - Add top = 16.dp to LazyColumn contentPadding so first card has extra spacing and slides nicely under the floating search header Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index f66fb1176e..9d361404b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -608,7 +608,7 @@ fun FeedScreen( // Reserve space for the header card that floats above. // When search is expanded, the card grows — add more margin. val headerSpacerHeight by animateDpAsState( - targetValue = if (searchActive) 300.dp else 76.dp, + targetValue = if (searchActive) 300.dp else 60.dp, animationSpec = tween(200), ) Spacer(Modifier.height(headerSpacerHeight)) @@ -682,7 +682,7 @@ fun FeedScreen( val sidePadding = LocalReadingSidePadding.current LazyColumn( state = lazyListState, - contentPadding = PaddingValues(horizontal = sidePadding + 12.dp), + contentPadding = PaddingValues(start = sidePadding + 12.dp, end = sidePadding + 12.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(loadedState.list, key = { it.idHex }) { note -> From dd5b61a6f096461231fd1db2bc6f509d67287fc6 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 29 May 2026 07:09:50 +0300 Subject: [PATCH 45/51] feat(desktop): add "All Screens" item before feeds in sidebar Opens the App Drawer (same as Cmd+K) for quick access to all available screen types. Positioned between main nav and FEEDS section. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index fa0ba33380..34554d5e18 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -192,11 +192,22 @@ fun MainSidebar( ) } + // -- All Screens -- + Spacer(Modifier.height(8.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(8.dp)) + + SidebarNavItem( + icon = MaterialSymbols.Apps, + label = "All Screens", + isActive = false, + expanded = expanded, + onClick = onAddColumn, + ) + // -- Custom feeds section -- if (allFeeds.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) AnimatedVisibility( visible = expanded, From 5667bd53c30361d52c9bb87dd6bbb9bba61883b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 09:29:48 +0000 Subject: [PATCH 46/51] fix(relays): keep lifecycle-aware grace timer running while backgrounded The grace-period unsubscribe in LifecycleAwareKeyDataSourceSubscription ran on the composition scope from rememberCoroutineScope(), whose dispatcher is coupled to the UI frame clock. When the app is backgrounded the frame clock stops ticking, so the pending unsubscribe could be starved and never fire. Because closing the REQ is what drives the relay disconnect (via desiredRelays -> RelayPool.updatePool), the connection could linger indefinitely. This is most visible on the relay feed, whose dedicated one-off relay is kept alive by nothing else. Drive the grace timer from Lifecycle.currentStateFlow on a dedicated Dispatchers.Default scope instead. collectLatest cancels the pending delay automatically when the lifecycle returns to STARTED, preserving the 30s app-switch grace while ensuring the timer fires reliably in the background. https://claude.ai/code/session_01SesftJphLwvLtn1fJB5zx8 --- ...LifecycleAwareKeyDataSourceSubscription.kt | 169 ++++++------------ 1 file changed, 57 insertions(+), 112 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt index 6a5df9fa91..ce86ad760c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt @@ -22,15 +22,17 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState -import kotlinx.coroutines.Job +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L @@ -47,6 +49,17 @@ private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L * rebuilding the relay REQ — which would otherwise lose EOSE state and * trigger a refetch on return. * + * The grace timer runs on a dedicated [Dispatchers.Default] scope driven by + * [Lifecycle.currentStateFlow] rather than on the composition's frame-clock + * coupled scope (`rememberCoroutineScope`). On a backgrounded app the UI + * frame clock stops ticking, so a timer scheduled there could be starved and + * the unsubscribe — and therefore the relay disconnect it triggers — might + * never run. This is most visible on the relay feed, whose dedicated one-off + * relay is kept connected by nothing else and would leak forever. Using a + * plain coroutine dispatcher keeps the timer firing while backgrounded; + * [collectLatest] cancels the pending delay automatically the moment the + * lifecycle returns to STARTED. + * * Use this for heavy feed subscriptions (home, video, discovery, chatroom list) * that should NOT run when the app is truly in the background. When an * always-on notification service keeps the relay client connected, these @@ -60,66 +73,11 @@ fun LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: ComposeSubscriptionManager, ) { - val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() - - DisposableEffect(state, lifecycleOwner) { - var isSubscribed = false - var pendingUnsubscribe: Job? = null - - fun subscribeNow() { - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (!isSubscribed) { - dataSource.subscribe(state) - isSubscribed = true - } - } - - fun scheduleUnsubscribe() { - if (!isSubscribed || pendingUnsubscribe != null) return - pendingUnsubscribe = - scope.launch { - delay(UNSUBSCRIBE_GRACE_MILLIS) - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - pendingUnsubscribe = null - } - } - - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - subscribeNow() - } - - Lifecycle.Event.ON_STOP -> { - scheduleUnsubscribe() - } - - else -> {} - } - } - - lifecycleOwner.lifecycle.addObserver(observer) - - if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - subscribeNow() - } - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - } - } + LifecycleAwareSubscription( + key = state, + subscribe = { dataSource.subscribe(state) }, + unsubscribe = { dataSource.unsubscribe(state) }, + ) } @Composable @@ -127,64 +85,51 @@ fun LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: MutableComposeSubscriptionManager, ) { - val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() + LifecycleAwareSubscription( + key = state, + subscribe = { dataSource.subscribe(state) }, + unsubscribe = { dataSource.unsubscribe(state) }, + ) +} - DisposableEffect(state, lifecycleOwner) { - var isSubscribed = false - var pendingUnsubscribe: Job? = null +@Composable +private fun LifecycleAwareSubscription( + key: Any?, + subscribe: () -> Unit, + unsubscribe: () -> Unit, +) { + val lifecycle = LocalLifecycleOwner.current.lifecycle - fun subscribeNow() { - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (!isSubscribed) { - dataSource.subscribe(state) - isSubscribed = true - } - } + DisposableEffect(key, lifecycle) { + // Background scope so the grace timer is not gated by the UI frame clock, + // which stops ticking while the app is backgrounded. + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - fun scheduleUnsubscribe() { - if (!isSubscribed || pendingUnsubscribe != null) return - pendingUnsubscribe = - scope.launch { + scope.launch { + // `subscribed` is confined to this single collector coroutine, so no + // cross-thread synchronization is needed for it. + var subscribed = false + lifecycle.currentStateFlow.collectLatest { current -> + if (current.isAtLeast(Lifecycle.State.STARTED)) { + if (!subscribed) { + subscribe() + subscribed = true + } + } else if (subscribed) { + // Stopped: keep the REQ alive for a short grace period. + // collectLatest cancels this delay if we return to STARTED first. delay(UNSUBSCRIBE_GRACE_MILLIS) - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - pendingUnsubscribe = null - } - } - - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - subscribeNow() - } - - Lifecycle.Event.ON_STOP -> { - scheduleUnsubscribe() - } - - else -> {} + unsubscribe() + subscribed = false } } - - lifecycleOwner.lifecycle.addObserver(observer) - - if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - subscribeNow() } onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } + scope.cancel() + // Idempotent: removing an absent key is a cheap no-op. Guarantees the + // subscription is released even if the grace timer was still pending. + unsubscribe() } } } From 350aadc5781ffb2c2d47d7082718117b33571884 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 29 May 2026 08:36:41 -0400 Subject: [PATCH 47/51] Pushes event hints for citations in Public Messages --- .../nipA4PublicMessages/PublicMessageEvent.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt index 6f308d9b8a..0c3c440769 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt @@ -24,10 +24,18 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag.Companion.parseAsHint import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt @@ -45,6 +53,8 @@ class PublicMessageEvent( sig: HexKey, ) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, + EventHintProvider, + AddressHintProvider, SearchableEvent { override fun indexableContent() = content @@ -52,6 +62,14 @@ class PublicMessageEvent( override fun linkedPubKeys() = tags.mapNotNull(ReceiverTag::parseKey) + citedNIP19().pubKeys() + override fun eventHints() = citedNIP19().eventHints() + + override fun linkedEventIds() = citedNIP19().eventIds() + + override fun addressHints() = citedNIP19().addressHints() + + override fun linkedAddressIds() = citedNIP19().addressIds() + fun isIncluded(pubKey: HexKey) = tags.any(ReceiverTag::match, pubKey) || this.pubKey == pubKey fun group() = tags.mapNotNullTo(mutableListOf(ReceiverTag(this.pubKey, null)), ReceiverTag::parse) From ca8589b29cd1c9f2889825403f1d19819edee63e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 29 May 2026 08:37:08 -0400 Subject: [PATCH 48/51] Quick check to make sure the gatherers are not duplicated --- .../com/vitorpamplona/amethyst/commons/model/Note.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 88b5505385..5050791cb4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -125,11 +125,17 @@ open class Note( fun inGatherers() = inGatherers ?: listOf().also { inGatherers = it } fun addGatherer(gatherer: NotesGatherer) { - inGatherers = inGatherers() + gatherer + val list = inGatherers() + if (gatherer !in list) { + inGatherers = inGatherers() + gatherer + } } fun removeGatherer(gatherer: NotesGatherer) { - inGatherers = inGatherers() - gatherer + val list = inGatherers() + if (gatherer in list) { + inGatherers = inGatherers() - gatherer + } } override fun removeNote(note: Note) { From 90109c90954ab3cf6416a2ee1e84c879ffe3012e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 29 May 2026 08:40:31 -0400 Subject: [PATCH 49/51] Removes the need of idHex on the BlankNote --- .../main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index d350ee09f6..a10c6225e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -59,7 +59,6 @@ fun BlankNotePreview() { @Composable fun BlankNote( modifier: Modifier = Modifier, - idHex: String? = null, shortPreview: Boolean = false, ) { Column(modifier = modifier) { @@ -81,7 +80,7 @@ fun BlankNote( if (shortPreview) { stringRes(R.string.post_not_found_short) } else { - stringRes(R.string.post_not_found) + if (idHex != null) ": $idHex" else "" + stringRes(R.string.post_not_found) }, modifier = Modifier.padding(30.dp), color = Color.Gray, From c1c4f7f72a6121f0641695019877b5837215ba36 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 29 May 2026 08:47:28 -0400 Subject: [PATCH 50/51] Uses the parent note's p-tags to load children notes that cannot be found --- .../amethyst/model/LocalCache.kt | 41 ++++++++--- .../event/loaders/FilterMissingEvents.kt | 28 ++++++++ .../UserFinderFilterAssemblerSubscription.kt | 32 +++++++++ .../amethyst/ui/note/WatchNoteEvent.kt | 2 + .../chats/rooms/ChatroomHeaderCompose.kt | 3 + ...LifecycleAwareKeyDataSourceSubscription.kt | 68 +++++++++++++++++++ 6 files changed, 164 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ef482f8a89..0ee9732d75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2121,7 +2121,10 @@ object LocalCache : ILocalCache, ICacheProvider { val requestId = event.requestId() val pending = when (val match = paymentTracker.onResponseReceived(requestId, event.pubKey)) { - is NwcPaymentTracker.MatchResult.Matched -> match.pending + is NwcPaymentTracker.MatchResult.Matched -> { + match.pending + } + is NwcPaymentTracker.MatchResult.WrongAuthor -> { // Possible spoof: a kind-23195 event from someone other than // the wallet service we sent the request to. The pending @@ -2134,6 +2137,7 @@ object LocalCache : ILocalCache, ICacheProvider { ) return false } + NwcPaymentTracker.MatchResult.NoMatch -> { Log.w( "LocalCache", @@ -2909,22 +2913,23 @@ object LocalCache : ILocalCache, ICacheProvider { relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { + // uses the internal event to avoid reprocessing cached items. + val note = + if (event is AddressableEvent) { + getOrCreateAddressableNote(event.address()) + } else { + getOrCreateNote(event.id) + } + val wasNew = justConsumeInnerInner(event, relay, wasVerified) if (wasNew) { updateHintIndexes(event) + updateInGatherer(event, note) } if (relay != null) { - // uses the internal event to avoid reprocessing cached items. - val note = - if (event is AddressableEvent) { - getAddressableNoteIfExists(event.address()) - } else { - getNoteIfExists(event.id) - } - - note?.event?.let { consumedEvent -> + note.event?.let { consumedEvent -> addIncomingRelayAsHintToAllRelatedEvents(consumedEvent, relay) } } @@ -2976,6 +2981,22 @@ object LocalCache : ILocalCache, ICacheProvider { } } + fun updateInGatherer( + event: Event, + note: Note, + ) { + if (event is EventHintProvider) { + event.linkedEventIds().forEach { + checkGetOrCreateNote(it)?.addGatherer(note) + } + } + if (event is AddressHintProvider) { + event.linkedAddressIds().forEach { + checkGetOrCreateAddressableNote(it)?.addGatherer(note) + } + } + } + @Suppress("DEPRECATION") private fun justConsumeInnerInner( event: Event, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt index 30818e74d7..94cf6ebdb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -66,6 +68,32 @@ fun potentialRelaysToFindEvent(note: Note): Set { childNote.author?.outboxRelays()?.let { set.addAll(it) } } + note.inGatherers?.forEach { parent -> + // loads from parent's relays, parent's authors relays and cited authors in the parent note. + // as well as relays from the channel and other fixed places. + when (parent) { + is Note -> { + set.addAll(parent.relays) + parent.author?.outboxRelays()?.let { set.addAll(it) } + parent.author?.inboxRelays()?.let { set.addAll(it) } + + val noteEvent = parent.event + if (noteEvent is PubKeyHintProvider) { + noteEvent.linkedPubKeys().forEach { potentialAuthor -> + LocalCache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor -> + potentialAuthor.outboxRelays()?.let { set.addAll(it) } + potentialAuthor.inboxRelays()?.let { set.addAll(it) } + } + } + } + } + + is Channel -> { + set.addAll(parent.relays()) + } + } + } + return set } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt index 04dd4f2dfa..52642b7e99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -25,8 +25,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @SuppressLint("StateFlowValueCalledInComposition") @Composable @@ -54,3 +56,33 @@ fun UserFinderFilterAssemblerSubscription( LifecycleAwareKeyDataSourceSubscription(state, dataSource) } + +/** + * Watches all the users that are cited in the parent post. + * So that we load their relay lists and find posts + * cited in this event. + */ +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun UserFinderByParentFilterAssemblerSubscription( + note: Note, + accountViewModel: AccountViewModel, +) { + note.inGatherers?.forEach { parent -> + when (parent) { + is Note -> { + val noteEvent = parent.event + if (noteEvent is PubKeyHintProvider) { + val states = + remember(parent) { + accountViewModel.loadUsersSync(noteEvent.linkedPubKeys()).map { + UserFinderQueryState(it, accountViewModel.account) + } + } + + LifecycleAwareKeyDataSourceSubscription(states, accountViewModel.dataSources().userFinder) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt index c6d242661d..1a33b4f7a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -51,6 +52,7 @@ fun WatchNoteEvent( accountViewModel = accountViewModel, nav = nav, ) { showPopup -> + UserFinderByParentFilterAssemblerSubscription(baseNote, accountViewModel) BlankNote( remember { modifier.combinedClickable( 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 dde17014f3..e7022890db 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 @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout @@ -96,6 +97,7 @@ fun ChatroomHeaderCompose( if (hasEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { + UserFinderByParentFilterAssemblerSubscription(baseNote, accountViewModel) BlankNote() } } @@ -167,6 +169,7 @@ private fun ChatroomEntry( } else -> { + UserFinderByParentFilterAssemblerSubscription(lastMessage, accountViewModel) BlankNote() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt index 6a5df9fa91..703eb5a9cc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState +import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus.state import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -122,6 +123,73 @@ fun LifecycleAwareKeyDataSourceSubscription( } } +@Composable +fun LifecycleAwareKeyDataSourceSubscription( + states: List, + dataSource: ComposeSubscriptionManager, +) { + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + + DisposableEffect(states, lifecycleOwner) { + var isSubscribed = false + var pendingUnsubscribe: Job? = null + + fun subscribeNow() { + pendingUnsubscribe?.cancel() + pendingUnsubscribe = null + if (!isSubscribed) { + dataSource.subscribe(states) + isSubscribed = true + } + } + + fun scheduleUnsubscribe() { + if (!isSubscribed || pendingUnsubscribe != null) return + pendingUnsubscribe = + scope.launch { + delay(UNSUBSCRIBE_GRACE_MILLIS) + if (isSubscribed) { + dataSource.unsubscribe(states) + isSubscribed = false + } + pendingUnsubscribe = null + } + } + + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + subscribeNow() + } + + Lifecycle.Event.ON_STOP -> { + scheduleUnsubscribe() + } + + else -> {} + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + subscribeNow() + } + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + pendingUnsubscribe?.cancel() + pendingUnsubscribe = null + if (isSubscribed) { + dataSource.unsubscribe(states) + isSubscribed = false + } + } + } +} + @Composable fun LifecycleAwareKeyDataSourceSubscription( state: T, From b1f0f04bcc233800ffbb2ab0a423548cb6c8639a Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Fri, 29 May 2026 13:02:55 +0000 Subject: [PATCH 51/51] New Crowdin translations by GitHub Action --- .../src/main/res/values-hu-rHU/strings.xml | 66 +++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 6 ++ 2 files changed, 72 insertions(+) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c82497ca09..946f0c27bc 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -131,6 +131,7 @@ Üzenet írása… Közzététel Mentés + Küldés Létrehozás Átnevezés Mégse @@ -561,6 +562,15 @@ Forrás: %1$s v%1$s Letöltés + Névjegy + Platformok + Témák + Hivatkozások + Legújabb kiadás + Megjegyzések + Legyen Ön az első hozzászóló. + Korábbi kiadások megjelenítése + Korábbi kiadások elrejtése Naptárak Rövidek Nyilvános csevegések @@ -721,6 +731,36 @@ Még nincsenek hangszoba-kiszolgálók. Adjon hozzá egyet alább, vagy válasszon egy ajánlott kiszolgálót. Videók Cikkek + Zene + Lejátszási listák + Részek + Hangműsor + Új zeneszám + Új lejátszási lista + Cím + Szám címe + Előadó + Előadó neve + Hang webcíme + https://… .mp3 + Borítókép webcíme (nem kötelező) + Album (nem kötelező) + Időtartam másodpercben (nem kötelező) + Dalszöveg vagy stáblista (nem kötelező) + Szám törlése + Közzétesz egy NIP-09 törlést ehhez a zeneszámhoz? + Hozzáadás a lejátszási listához + Hozzáadás a lejátszási listához + Önnek még nincs lejátszási listája. A fenti mező segítségével hozhat létre egyet. + Összes lejátszási lista kezelése + Új lejátszási lista + Lejátszási lista neve + Létrehozás + (cím nélküli lejátszási lista) + + %1$d szám + %1$d szám + Privát könyvjelzők Nyilvános könyvjelzők Hozzáadás a privát könyvjelzőkhöz @@ -1360,6 +1400,26 @@ Megjegyzés egy weboldalhoz Megjegyzés egy külső forráshoz Olvasási idő: %1$d perc + + %1$d szám + %1$d szám + + + +%1$d szám + +%1$d szám + + Közreműködési + Privát + Ismeretlen szám + Szám betöltése… + Egy borítókép feltöltése + Válasszon ki egy négyzet alakú képet, amely a szám borítójaként fog szolgálni. + Egy hangfájl feltöltése + Válasszon ki egy MP3-, WAV- vagy FLAC-fájlt. A „Mentés” gombra koppintva a fájl feltöltődik a médiakiszolgálóra. + A hangfájl készen áll a feltöltésre + Feltöltés… + Borító és hangfájl feltöltése, majd közzététel. Ez akkor is folytatódik, ha elhagyja a képernyőt. + Nem sikerült feltölteni: %1$s Helyszín betöltése… A helyszín-meghatározás nincs engedélyezve Hozzáadja az érzékeny tartalomra vonatkozó figyelmeztetést a tartalom megjelenítése előtt. Ez ideális bármilyen NSFW tartalom vagy olyan tartalom esetén, amelyet egyesek sértőnek vagy zavarónak találhatnak @@ -2260,6 +2320,12 @@ Felhasználói beállítások Audio-fejléc Hangsáv + Zeneszám + Zenei lejátszási lista + Hangműsor-rész + Hangműsor + Saját készítésű hangműsorok + Kedvenc hangműsorok Kitűződíjak Kitűzők meghatározása Elfogadott kitűzőlista diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 023af94949..184c82a7f8 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -725,6 +725,8 @@ 文章 音乐 播放列表 + 节目 + 播客 新音乐曲目 新建播放列表 标题 @@ -2305,6 +2307,10 @@ 音轨 音乐曲目 音乐播放列表 + 播客节目 + 播客秀 + 创作的播客 + 收藏的播客 徽章奖励 徽章定义 接受的徽章集