From de2c970e3edb01ed90544e6688719b000dff5bcb Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 30 Jun 2026 12:00:46 +0300 Subject: [PATCH] feat(desktop): Follow Packs (NIP-51 kind 39089) discovery and follow flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Follow Packs experience to Amethyst Desktop: - New "Discover" sidebar destination with featured-pack hero, hashtag chips driven by NIP-12 `t` tags, a 3-up "From the pack" notes feed, and a right-rail of mini pack thumbnails. - New "Follow Packs" launchable column (App Drawer + Discover "Browse all") with multi-field search across title, description, creator name/npub, and `t` tags. - Pack detail overlay (read-only) with per-member Follow/Unfollow buttons that reflect the live kind-3 state, plus pack-level Follow all / Unfollow all with a dedupe-aware confirm dialog ("Follow N new (M already followed)"). - Bulk follow / unfollow batched into a single kind-3 publish via new `FollowActions.buildUnfollowBatch` and `Kind3FollowListState.follow/ unfollow(users: List)`. The mutating call sites are Mutex- protected against concurrent races. - naddr → 39089 references in notes render as a rich inline card with avatar stack + Follow all CTA. Cache miss triggers a one-shot subscription; empty / deleted packs render minimal states. - Shuffle button rotates both the featured pack and the gallery, excluding the last 5 shown. - Pack image fields render via Coil `AsyncImage` with a deterministic gradient fallback. Protocol additions: - Quartz: `FollowListEvent.hashtags()` convenience accessor. Bug fixes wrapped into the feature: - `DesktopLocalCache.consumeContactList` now also loads the event into `addressableNotes` so `Kind3FollowListState.getFollowListEvent()` returns the user's actual kind-3. Without this, every bulk follow silently replaced (rather than appended to) the contact list. - Added Material Symbols `Shuffle` codepoint and regenerated the bundled subset font (still 432 KB). --- .../font/material_symbols_outlined.ttf | Bin 460520 -> 461232 bytes .../amethyst/commons/actions/FollowActions.kt | 26 ++ .../commons/icons/symbols/MaterialSymbols.kt | 1 + .../nip02FollowList/Kind3FollowListState.kt | 54 ++- .../vitorpamplona/amethyst/desktop/Main.kt | 19 + .../desktop/cache/DesktopLocalCache.kt | 47 +++ .../desktop/followpacks/BulkFollowAction.kt | 125 ++++++ .../desktop/followpacks/FollowPackEditor.kt | 48 +++ .../desktop/followpacks/FollowPacksState.kt | 199 +++++++++ .../desktop/followpacks/MetadataPrefetch.kt | 59 +++ .../desktop/followpacks/ui/DiscoverScreen.kt | 352 ++++++++++++++++ .../followpacks/ui/FollowAllConfirmDialog.kt | 156 +++++++ .../ui/FollowPackBrowseAllScreen.kt | 162 +++++++ .../followpacks/ui/FollowPackComponents.kt | 361 ++++++++++++++++ .../followpacks/ui/FollowPackDetailScreen.kt | 396 ++++++++++++++++++ .../desktop/followpacks/ui/FromThePackFeed.kt | 283 +++++++++++++ .../followpacks/ui/RenderFollowPackCard.kt | 238 +++++++++++ .../amethyst/desktop/ui/deck/AppDrawer.kt | 4 + .../amethyst/desktop/ui/deck/ColumnHeader.kt | 2 + .../desktop/ui/deck/DeckColumnContainer.kt | 68 +++ .../desktop/ui/deck/DeckColumnType.kt | 8 + .../amethyst/desktop/ui/deck/DeckSidebar.kt | 1 + .../amethyst/desktop/ui/deck/DeckState.kt | 2 + .../desktop/ui/deck/LocalFeedProvider.kt | 5 + .../desktop/ui/deck/SinglePaneLayout.kt | 4 + .../desktop/ui/note/DesktopRichTextViewer.kt | 23 + .../nip51Lists/followList/FollowListEvent.kt | 3 + 27 files changed, 2630 insertions(+), 16 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 9aeb9602e5f2b87fdfd7294d994a47737760e249..43a1428fc8ca5a7d234ae119bff78612c6c2c864 100644 GIT binary patch delta 2313 zcmb7Fdr(u^8UN1BJvWd|j zt<`lc-RyQ(r>m@W9H;A6G`=5HSzD(LTRUAl&hD@@zS_WE)&L0*l8}Cvt+V~NXYxDW z`M%%xINv!riT`bLtHTxOoSo)>c%`uiPKl{yxB?DC&0{I~-d<)!hp4Hx0oLI3FuXpldV^SGfF)F(7-+`e=9XVsIDzW~698#dQCem2y47vOTU zq;GWW+(JGewWv#Vg-s62#`UYpTz|pM`>;dRmd)F?_ublh4&bf`z}I-qB>TW(8MHu) zOppnX09wFm+0&-x%t)O!mCwi_StzomQPi1&2ItJZ_SlsoGTacbj~>5Ybic)prn&%H z?12U+8oYB4KF-x*gZODAHIRw>z@^7^GsXMbxeDeHq%yVbJ%`jd$@4o!XpbKrSnTSm7XYlx+r_H_Gk`}1YaWe>}LT>hYS7b%YU~#w&KkdXIF?Tb5?%5imobK_0cN->d9kY7$*4BVpss) zqHDVt(&A~oaon@)=FzlM*hv$1CWj`U!k6Xyo^Oh;&^N<3+sm~bAl_g9aR2`i+M5u8oq z9>3sFeu<%Z_-f${;CmgzckD@AN{~j2l)!Z8TLtI2B%2hH>9CHNiG@riIUdWMx%sQ% zJlNnAMm>c`lB|}h5(lXv>q$L%!?U)lDzR~F*9I)q)`f-0cJJv&N+wYxjncJn7rZx~9A%xm zD}mi(1a{vAVBMR5J(vgV;qAb_dji;_dSHL+1NJ1^PTPS!lMC!8^MF08L$m^W?g6mp zZvlG&<;8ozUS$y|uNNX(fW2`I*!FT@?^=NU>snwRoC3BBfb|^&_Q`HwdpK~i;w4}s zQJ{FQG@%Y?vJR*|52!F5X!dTPFK+^x|0U3wwLs^51GJa{YHbH8$G7tnBS_d?Y0Wl+R+%JF|Cjh6t0i6Etz?m4}(!t%_y}!AeAQ*(~ za&tF(cBeBReav}6Hrow5+MU!JRXDa;Gifb@v(SZ6bA?8 zd{PsyQz+yhV;G$-Nh0I%XuR+1u$!k;DO0AT2!h|Iq*M@6QbIm|%+33HdqdG+)EV{p z{1Pqs83d)9*Jyf2f_}fxXA*if8f5)S!Nj?F!C+8QZ6F{BCW)2Yp7}yYlq2Iyy9k6I3c^M~6$M zEL8u?4TUawa&Ko}Utys}qSfjN#=ee_n;#hwqscN^X68VbI5Lu%DU{_4C3+l;9yuH(TgM~S*M#H z9gV~zLqmapN)s6!4FraUIE{+IgHR|qhGQ6wMkTS5TQCs$Kl8i!SS&mm4hBV0tqaFu zq8JRyb!tqnQ>o;P+$r6dTB0StJ~8a(LZKcd-{0?I72pqrT(rMmgv3ES(4?eqhSO9k z7n6uA7YueQh9JewGYlosczk$RmPi?9csL$s6J?`rK9LX#k$5~YK_;=NF|pyOd!Gsl VAxn@WSjgIMTSqV3)^W#9{s#%x;IIGy delta 1571 zcmYjR2~1Q+82)CNSx&iR7u;sGa1W|M>oE{+ah$4T5=}PzD0P zg>Mwjn#AO=gZJuffjbl+d$lQ{2~}R@*MQy{z)BvQsGb`d|5galS;V8(CMJa@RXixF z1Nxe{-lH$pZ-&EQE6|_D^|-78ec{OIfcLro4M2K6m*drs3R-~Q^ErNZoqkJMAs3v1 z+oPP@tjjOW^?f7sFfT3uXX!c~kdPvBsJ{tPpWwV4eRkmC>MywRX1+NJ$kL4hnYzdLL6vEy6CV*NB3YQs1G#9p);ZB=Xwyl`nj_4S z^2of%mMEL36;WHGu4)`KL7GI(dQGvWR&!i)QS*~#OzW!k*9K|hw5{6b%Qr8-p;PEI zy6w77-JdI}R@{o#Mz=;kTDf{O+lem(&EAeUZtqBnc&51sV zsfi7VSChh%>XJ;!6OuENKTQ#5rfg2RoNArABlTulcv@xJ<#eR)PVdd|%h;CD^`^wq zD<{e0?F$!`CK)R`N+kxX&Laedqn!=PlN>GQxHZa6b|xRw4AWdwfGNz-GGSGAb$^|pA>P}AiZ@90@0JQOwx`-CP>m9R@#2nhdhk_ov&u;48$76Jt+pp+}+ ze0Noj4_XPum|{qorO+r83Z>$UNlz6)iVOvv)Ho^EFxI7(?eo~_A@TUo{j$3faDV0= z?xFKoY6$E0Sw!V$-I;v7{JQ*axxcJbHY7`sJ(v5sncd8?-LeI4)h7F)isnb+P{;2H-?X#`($46*!d9_F}pIkB7W_WnXxAu}$LgEY$ zyWN6FFbR3(^GYBA+?mt=7xA-b#CLq3LT2&s$zH_qr3my47_sSOvPdSgwMJ=P9q$D_(E)n$N6=HpK%0X>PiKO*#(widLF^Y(7guV;aFXhAQS zKzmMses>@AdI0FJ`$2m>K>KV!|2P0T6oE4Ovz`|<4BICeTa(g_tuPOx79|9|w4YmbfJ2!ysZUn1c19r#{tRV{QmXb0BAZQl{sYeZ@!v6QngB9jM!b!r(s?9t|PnT5K=CC%~9O>tceB}QDDKWF1 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt index 76ca908515..63ae6a7fea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt @@ -121,4 +121,30 @@ object FollowActions { } else { null } + + /** + * Batch-unfollow variant — removes every pubkey in [pubkeysToRemove] from + * the follow set in a single kind:3 update. + * + * Returns the original [currentContactList] if none of the pubkeys were + * present (no-op), or `null` when [currentContactList] is empty/null. + */ + suspend fun buildUnfollowBatch( + signer: NostrSigner, + pubkeysToRemove: Set, + currentContactList: ContactListEvent?, + ): ContactListEvent? { + if (currentContactList == null || currentContactList.tags.isEmpty()) return null + if (pubkeysToRemove.isEmpty()) return currentContactList + val newTags = + currentContactList.tags + .filter { tag -> !(tag.size > 1 && tag[0] == "p" && tag[1] in pubkeysToRemove) } + .toTypedArray() + if (newTags.size == currentContactList.tags.size) return currentContactList + return ContactListEvent.create( + content = currentContactList.content, + tags = newTags, + signer = signer, + ) + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 168fe2f1b5..603098e77a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -214,6 +214,7 @@ object MaterialSymbols { val SettingsInputAntenna = MaterialSymbol("\uE8BF") val Share = MaterialSymbol("\uE80D") val Shield = MaterialSymbol("\uE9E0") + val Shuffle = MaterialSymbol("\uE043") val SkipNext = MaterialSymbol("\uE044") val SkipPrevious = MaterialSymbol("\uE045") val SmartDisplay = MaterialSymbol("\uF06A") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt index d15c13f040..c09c69ef06 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt @@ -42,6 +42,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class Kind3FollowListState( val signer: NostrSigner, @@ -113,27 +115,47 @@ class Kind3FollowListState( ) } + // Serializes all kind:3 mutations so concurrent follow/unfollow/follow-all + // calls cannot lose writes. The read of getFollowListEvent() inside each + // call happens under the lock, so the dedupe baseline is always fresh. + private val followMutex = Mutex() + suspend fun follow(users: List): ContactListEvent = - FollowActions.buildFollowBatch( - signer = signer, - pubkeysWithHints = users.map { it.pubkeyHex to it.bestRelayHint() }, - currentContactList = getFollowListEvent(), - ) + followMutex.withLock { + FollowActions.buildFollowBatch( + signer = signer, + pubkeysWithHints = users.map { it.pubkeyHex to it.bestRelayHint() }, + currentContactList = getFollowListEvent(), + ) + } suspend fun follow(user: User): ContactListEvent = - FollowActions.buildFollow( - signer = signer, - pubkeyToFollow = user.pubkeyHex, - currentContactList = getFollowListEvent(), - relayHint = user.bestRelayHint(), - ) + followMutex.withLock { + FollowActions.buildFollow( + signer = signer, + pubkeyToFollow = user.pubkeyHex, + currentContactList = getFollowListEvent(), + relayHint = user.bestRelayHint(), + ) + } suspend fun unfollow(user: User): ContactListEvent? = - FollowActions.buildUnfollow( - signer = signer, - pubkeyToUnfollow = user.pubkeyHex, - currentContactList = getFollowListEvent(), - ) + followMutex.withLock { + FollowActions.buildUnfollow( + signer = signer, + pubkeyToUnfollow = user.pubkeyHex, + currentContactList = getFollowListEvent(), + ) + } + + suspend fun unfollow(users: List): ContactListEvent? = + followMutex.withLock { + FollowActions.buildUnfollowBatch( + signer = signer, + pubkeysToRemove = users.mapTo(mutableSetOf()) { it.pubkeyHex }, + currentContactList = getFollowListEvent(), + ) + } init { settings.backupContactList?.let { 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 89667ec42d..1ae39ba960 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -190,6 +190,12 @@ sealed class DesktopScreen { data object Settings : DesktopScreen() data object LocalRelaySettings : DesktopScreen() + + data class FollowPackDetail( + val addressTag: String, + ) : DesktopScreen() + + data object FollowPackBrowseAll : DesktopScreen() } /** Reference to active Tor manager for shutdown hook. Set by App composable. */ @@ -1239,6 +1245,18 @@ fun MainContent( DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays) } + // Follow Packs state — single per-account holder for Discover + sidebar + naddr cards + val followPacksState = + remember(iAccount, localCache, relayManager, scope) { + com.vitorpamplona.amethyst.desktop.followpacks + .FollowPacksState( + cache = localCache, + relayManager = relayManager, + kind3FollowList = iAccount.kind3FollowList, + scope = scope, + ) + } + // Aggregated relay categories (feed, notifications, search, DM) val relayCategories = remember(iAccount.nip65RelayList, accountRelays, relayManager) { @@ -1497,6 +1515,7 @@ fun MainContent( com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayHealthStore provides relayHealthStore, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayListMutator provides relayListMutator, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState provides followPacksState, ) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index a8d6ac087b..d32ba9093f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEve import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.DualCase @@ -272,6 +273,10 @@ class DesktopLocalCache : ICacheProvider { consumeOldBookmarkList(event) } + is FollowListEvent -> { + consumeFollowList(event) + } + is CommentEvent -> { consumeComment(event, relay) } @@ -480,6 +485,14 @@ class DesktopLocalCache : ICacheProvider { lastContactListCreatedAt = event.createdAt lastContactListEvent = event _followedUsers.value = event.verifiedFollowKeySet() + + // Store in addressableNotes too — Kind3FollowListState.getFollowListEvent + // reads from getOrCreateAddressableNote(...) and would otherwise see a + // null event, causing buildFollowBatch to start a fresh kind-3 (wiping + // existing follows). + val addressableNote = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) return true } @@ -530,6 +543,40 @@ class DesktopLocalCache : ICacheProvider { return true } + /** + * Consumes a kind 39089 follow pack event (NIP-51). + * Addressable replaceable event — keyed by (kind, pubkey, dTag). + * Bumps [followPackVersion] so observers can refresh their snapshot. + */ + private val _followPackVersion = MutableStateFlow(0L) + val followPackVersion: StateFlow = _followPackVersion.asStateFlow() + + private fun consumeFollowList(event: FollowListEvent): Boolean { + val address = event.address() + val addressableNote = getOrCreateAddressableNote(address) + val author = getOrCreateUser(event.pubKey) + + val existingEvent = addressableNote.event + if (existingEvent != null && existingEvent.createdAt >= event.createdAt) return false + + addressableNote.loadEvent(event, author, emptyList()) + _followPackVersion.value++ + return true + } + + /** + * Snapshot all 39089 (FollowListEvent) addressable notes currently in cache. + * Use [followPackVersion] to drive reactive recomputation. + */ + fun snapshotFollowPacks(): List { + val out = mutableListOf() + addressableNotes.forEach { _, note -> + val ev = note.event + if (ev is FollowListEvent) out.add(ev) + } + return out + } + // ----- NWC Payment operations ----- /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt new file mode 100644 index 0000000000..bcae97ce02 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt @@ -0,0 +1,125 @@ +/* + * 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.followpacks + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +data class BulkFollowPreview( + val totalMembers: Int, + val newUsers: List, + val existingUsers: List, +) { + val newCount: Int get() = newUsers.size + val existingCount: Int get() = existingUsers.size +} + +/** + * Stateless helpers for the Follow-all action. + * + * computePreview() resolves pack `p` tags to materialized Users + * (creating placeholders for unknown pubkeys so relay hints survive), + * and partitions them into new vs already-followed. + * + * commit() forwards to Kind3FollowListState.follow(users) which is + * Mutex-guarded against concurrent calls and dedupes inside Quartz. + */ +object BulkFollowAction { + /** + * Resolves every pubkey in [pack] to a User, including those without + * cached metadata (so the bulk-follow always carries relay hints). + */ + fun computePreview( + pack: FollowListEvent, + cache: DesktopLocalCache, + currentFollows: Set, + myPubkey: HexKey, + ): BulkFollowPreview { + val newUsers = mutableListOf() + val existingUsers = mutableListOf() + pack.follows().forEach { userTag -> + // Skip self-follow attempts (gap N6) + if (userTag.pubKey == myPubkey) return@forEach + val user = cache.getOrCreateUser(userTag.pubKey) + if (userTag.pubKey in currentFollows) { + existingUsers.add(user) + } else { + newUsers.add(user) + } + } + return BulkFollowPreview( + totalMembers = pack.follows().size, + newUsers = newUsers, + existingUsers = existingUsers, + ) + } + + /** + * Publishes the bulk-follow. Returns the new ContactListEvent or null if + * publish/sign failed. + */ + suspend fun commit( + usersToAdd: List, + iAccount: DesktopIAccount, + relayManager: RelayConnectionManager, + cache: DesktopLocalCache, + ): Boolean { + if (!iAccount.isWriteable()) return false + if (usersToAdd.isEmpty()) return true + return try { + val newContactList = iAccount.kind3FollowList.follow(usersToAdd) + cache.consume(newContactList, null, wasVerified = false) + relayManager.broadcastToAll(newContactList) + true + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + false + } + } + + /** Unfollows every pack member that the user currently follows. */ + suspend fun commitUnfollowAll( + pack: FollowListEvent, + iAccount: DesktopIAccount, + relayManager: RelayConnectionManager, + cache: DesktopLocalCache, + ): Boolean { + if (!iAccount.isWriteable()) return false + val toRemove = + pack.followIds().mapNotNull { hex -> + if (hex != iAccount.pubKey) cache.getOrCreateUser(hex) else null + } + if (toRemove.isEmpty()) return true + return try { + val newContactList = iAccount.kind3FollowList.unfollow(toRemove) ?: return false + cache.consume(newContactList, null, wasVerified = false) + relayManager.broadcastToAll(newContactList) + true + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + false + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.kt new file mode 100644 index 0000000000..0e11b07b30 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.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.desktop.followpacks + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +/** + * Lightweight helpers for follow-pack viewing actions. + * + * v1 scope: view + follow + share. No create/edit/delete. + */ +object FollowPackEditor { + /** Encodes the pack as a `nostr:naddr1…` URI for sharing via clipboard. */ + fun toShareUri(pack: FollowListEvent): String { + val naddr = + com.vitorpamplona.quartz.nip19Bech32.entities + .NAddress + .create( + kind = FollowListEvent.KIND, + pubKeyHex = pack.pubKey, + dTag = pack.dTag(), + relays = emptyList(), + ) + return "nostr:$naddr" + } + + /** Address coordinate (e.g. "39089::") — stable identity. */ + fun aTag(pack: FollowListEvent): String = Address.assemble(FollowListEvent.KIND, pack.pubKey, pack.dTag()) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt new file mode 100644 index 0000000000..ae95ded0e9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt @@ -0,0 +1,199 @@ +/* + * 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.followpacks + +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Single state holder for everything follow-pack-related on Desktop. + * + * Sources: + * - Featured pack: random pick from cached 39089 events (refreshed by Shuffle). + * - Followed-author packs: 39089 events authored by accounts the user follows. + * - All packs: every 39089 in the cache (used by Discover gallery / Browse-all). + * + * Subscribes to a multi-relay set for global pack discovery. + * (relay.following.space doesn't actually exist — using popular indexer relays.) + */ +class FollowPacksState( + private val cache: DesktopLocalCache, + private val relayManager: RelayConnectionManager, + private val kind3FollowList: Kind3FollowListState, + private val scope: CoroutineScope, +) { + private val followedAuthors: StateFlow> = + kind3FollowList.flow + .map { it.authors } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = kind3FollowList.flow.value.authors, + ) + + companion object { + private const val SUB_ID = "discover-follow-packs" + + // Multi-relay set for global pack discovery. + // following.space (mentioned in plan) does NOT have its own relay — + // it uses these popular relays. Verified empirically. + val DISCOVERY_RELAYS: Set = + listOfNotNull( + RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"), + RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band"), + RelayUrlNormalizer.normalizeOrNull("wss://relay.primal.net"), + RelayUrlNormalizer.normalizeOrNull("wss://nos.lol"), + ).toSet() + } + + /** All 39089 events currently in the cache. Recomputes on cache change. */ + val allPacks: StateFlow> = + cache.followPackVersion + .map { cache.snapshotFollowPacks().sortedByDescending { it.createdAt } } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = cache.snapshotFollowPacks().sortedByDescending { it.createdAt }, + ) + + /** Packs authored by accounts the user follows. Top-3 for sidebar. */ + val followedAuthorsPacks: StateFlow> = + combine(allPacks, followedAuthors) { packs, followed -> + packs.filter { it.pubKey in followed } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + ) + + /** + * Sidebar top-3: own packs + packs by people user follows. + * Falls back to most-recent-from-relays when user has no follow-derived packs. + */ + val sidebarTopPacks: StateFlow> = + followedAuthorsPacks + .map { followedPacks -> + if (followedPacks.isNotEmpty()) { + followedPacks.take(3) + } else { + // empty-state fallback: most recent from cache (any author) + allPacks.value.take(3) + } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + ) + + // ---- Featured pack with shuffle ---- + + private val shuffleTickInternal = MutableStateFlow(0L) + + /** Increments on every shuffle() call. Used by gallery to re-randomize order. */ + val shuffleTick: StateFlow = shuffleTickInternal.asStateFlow() + + private val recentlyShown = ArrayDeque(5) + + private val featuredPackInternal = MutableStateFlow(null) + val featuredPack: StateFlow = featuredPackInternal.asStateFlow() + + init { + // Recompute featured on cache or shuffle change. + scope.launch(Dispatchers.Default) { + combine(allPacks, shuffleTickInternal) { packs, _ -> packs }.collect { packs -> + featuredPackInternal.value = pickRandom(packs) + } + } + + // Subscribe to discovery relays for global pack feed. + subscribeToDiscovery() + } + + fun shuffle() { + shuffleTickInternal.value++ + } + + private fun pickRandom(packs: List): FollowListEvent? { + if (packs.isEmpty()) return null + val candidates = packs.filter { it.address().toValue() !in recentlyShown } + val pool = if (candidates.isEmpty()) packs else candidates + val pick = pool.random() + rememberShown(pick.address().toValue()) + return pick + } + + private fun rememberShown(aTag: String) { + if (recentlyShown.size >= 5) recentlyShown.removeFirst() + recentlyShown.addLast(aTag) + } + + private fun subscribeToDiscovery() { + val listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + cache.consume(event, relay) + } + } + + // For each discovery relay, ensure it's added then subscribe. + scope.launch { + // small grace period for relay manager to settle + delay(500) + DISCOVERY_RELAYS.forEach { relay -> + relayManager.subscribeOnRelay( + relay = relay, + subId = "$SUB_ID-${relay.url.hashCode()}", + filters = listOf(Filter(kinds = listOf(FollowListEvent.KIND), limit = 200)), + onEvent = { event, r -> cache.consume(event, r) }, + ) + // Suppress unused variable warning + @Suppress("UNUSED_VARIABLE") + val unused = listener + } + } + } + + /** Returns hashtags present on this pack, lowercase + deduped. */ + fun hashtagsFor(pack: FollowListEvent): List = pack.hashtags().map { it.lowercase() }.distinct() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt new file mode 100644 index 0000000000..240056ddf1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt @@ -0,0 +1,59 @@ +/* + * 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.followpacks + +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Subscribes for kind:0 metadata events for the given pubkeys across the + * default relays, forwarding incoming events to the cache. + * + * Returns a closer lambda — caller is responsible for unsubscribing via + * DisposableEffect's onDispose. + */ +fun RelayConnectionManager.subscribeMetadataFor( + pubkeys: List, + cache: DesktopLocalCache, +): () -> Unit { + if (pubkeys.isEmpty()) return { } + val subId = "pack-members-meta-${pubkeys.hashCode()}" + val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = pubkeys) + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + cache.consume(event, relay) + } + } + subscribe(subId, listOf(filter), listener = listener) + return { unsubscribe(subId) } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt new file mode 100644 index 0000000000..d362815d23 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt @@ -0,0 +1,352 @@ +/* + * 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.followpacks.ui + +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.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 +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.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 coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor +import com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState +import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.coroutines.launch +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +@Composable +fun DiscoverScreen( + state: FollowPacksState, + cache: DesktopLocalCache, + iAccount: DesktopIAccount, + relayManager: RelayConnectionManager, + onOpenPack: (String) -> Unit, + onOpenBrowseAll: () -> Unit, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + @Suppress("UNUSED_PARAMETER") onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + val featured by state.featuredPack.collectAsState() + val allPacks by state.allPacks.collectAsState() + + var selectedChip by remember { mutableStateOf(null) } + var showFollowAll by remember { mutableStateOf(false) } + + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + val current = featured + val followedAuthors by iAccount.kind3FollowList.flow.collectAsState() + val shuffleTick by state.shuffleTick.collectAsState() + val fullyFollowed = + remember(current, followedAuthors) { + current != null && current.followIds().isNotEmpty() && + current.followIds().all { it == iAccount.pubKey || it in followedAuthors.authors } + } + val galleryPacks = + remember(allPacks, selectedChip, current, shuffleTick) { + val others = allPacks.filter { it.id != current?.id } + val filtered = + if (selectedChip == null) { + others + } else { + others.filter { pack -> + pack.hashtags().any { it.equals(selectedChip, ignoreCase = true) } + } + } + // Reshuffle order on every shuffle tick so the right rail rotates too. + if (shuffleTick > 0) filtered.shuffled() else filtered + } + + // Prefetch member metadata for the featured pack so avatars + names show up. + DisposableEffect(current?.id) { + val pack = current + if (pack != null) { + val handle = relayManager.subscribeMetadataFor(pack.followIds().take(50), cache) + onDispose { handle() } + } else { + onDispose { } + } + } + + val onShareCurrent: () -> Unit = + remember(current) { + { + current?.let { + copyToClipboardLocal(FollowPackEditor.toShareUri(it)) + scope.launch { + snackbarHostState.showSnackbar("Pack link copied to clipboard") + } + } + } + } + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // ---- Top section: Row(Hero | RightRail) ---- + if (current == null) { + EmptyDiscoverHero(onOpenBrowseAll = onOpenBrowseAll) + } else { + Row(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.weight(2f)) { + FollowPackHeroCard( + pack = current, + cache = cache, + onFollowAll = { showFollowAll = true }, + onShare = onShareCurrent, + onOpenDetail = { onOpenPack(FollowPackEditor.aTag(current)) }, + fullyFollowed = fullyFollowed, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.width(12.dp)) + Column( + modifier = Modifier.width(220.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + galleryPacks.take(4).forEach { pack -> + MiniPackThumb( + pack = pack, + onClick = { onOpenPack(FollowPackEditor.aTag(pack)) }, + ) + } + if (galleryPacks.size > 4) { + Text( + text = "Browse all ${galleryPacks.size}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .padding(top = 4.dp) + .clickable(onClick = onOpenBrowseAll), + ) + } + } + } + } + + // ---- Topic chips ---- + val chipTags = remember(current) { current?.let { state.hashtagsFor(it) } ?: emptyList() } + if (chipTags.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + Text( + text = "TOPICS", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + FollowPackChipRail( + tags = chipTags, + selected = selectedChip, + onSelect = { selectedChip = it }, + ) + } + + // ---- From the pack ---- + if (current != null && current.followIds().isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = "From the pack", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { state.shuffle() }) { + Icon( + symbol = MaterialSymbols.Shuffle, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Shuffle") + } + } + Spacer(Modifier.height(8.dp)) + FromThePackFeed( + memberHexes = current.followIdSet(), + cache = cache, + relayManager = relayManager, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + } + + // Snackbar host overlay at bottom — confirms Share copy etc. + SnackbarHost( + hostState = snackbarHostState, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + ) { data -> + Snackbar(snackbarData = data) + } + } + + if (showFollowAll && current != null) { + FollowAllConfirmDialog( + pack = current, + iAccount = iAccount, + cache = cache, + relayManager = relayManager, + onDismiss = { showFollowAll = false }, + ) + } +} + +@Composable +private fun MiniPackThumb( + pack: FollowListEvent, + onClick: () -> Unit, +) { + val title = pack.title()?.ifBlank { null } ?: "Untitled pack" + val image = pack.image()?.ifBlank { null } + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 72.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + Color( + (title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(), + ), + ).clickable(onClick = onClick), + ) { + if (image != null) { + AsyncImage( + model = image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(72.dp), + ) + } else { + Box(modifier = Modifier.fillMaxWidth().height(72.dp)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .clip(MaterialTheme.shapes.small) + .background(color = MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun EmptyDiscoverHero(onOpenBrowseAll: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Discover follow packs", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Curated lists of people you can follow in one tap.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onOpenBrowseAll) { Text("Browse packs") } + } +} + +internal fun copyToClipboardLocal(text: String) { + runCatching { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.kt new file mode 100644 index 0000000000..d5ffe5d4e5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.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.amethyst.desktop.followpacks.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.unit.dp +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.followpacks.BulkFollowAction +import com.vitorpamplona.amethyst.desktop.followpacks.BulkFollowPreview +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Confirm dialog for bulk follow / unfollow. Detects current state and switches + * the primary action between "Follow N" and "Unfollow M" automatically. + * + * Cancel is the safer default. + */ +@Composable +fun FollowAllConfirmDialog( + pack: FollowListEvent, + iAccount: DesktopIAccount, + cache: DesktopLocalCache, + relayManager: RelayConnectionManager, + onDismiss: () -> Unit, + onSuccess: () -> Unit = {}, + onFailure: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + var publishing by remember { mutableStateOf(false) } + var preview by remember { mutableStateOf(null) } + + // Recompute on every kind-3 change so the dialog reflects current state + val current by iAccount.kind3FollowList.flow.collectAsState() + LaunchedEffect(pack.id, current.authors) { + preview = + BulkFollowAction.computePreview(pack, cache, current.authors, iAccount.pubKey) + } + + val p = preview ?: return + val isFullyFollowed = p.newCount == 0 && p.existingCount > 0 + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = if (isFullyFollowed) "Unfollow this pack?" else "Follow this pack?", + style = MaterialTheme.typography.titleMedium, + ) + }, + text = { + Column { + Text( + text = pack.title()?.ifBlank { null } ?: "Untitled pack", + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + when { + isFullyFollowed -> + "Unfollow all ${p.existingCount} ${if (p.existingCount == 1) "person" else "people"} in this pack?" + p.newCount == 0 -> "Nothing to do." + else -> + "Follow ${p.newCount} new ${if (p.newCount == 1) "person" else "people"}" + + if (p.existingCount > 0) " (${p.existingCount} already followed)." else "." + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + confirmButton = { + TextButton( + enabled = !publishing && (p.newCount > 0 || isFullyFollowed), + onClick = { + publishing = true + scope.launch { + val freshCurrent = iAccount.kind3FollowList.flow.value.authors + val refreshed = + BulkFollowAction.computePreview(pack, cache, freshCurrent, iAccount.pubKey) + val fullyFollowedNow = refreshed.newCount == 0 && refreshed.existingCount > 0 + val ok = + if (fullyFollowedNow) { + BulkFollowAction.commitUnfollowAll( + pack = pack, + iAccount = iAccount, + relayManager = relayManager, + cache = cache, + ) + } else { + BulkFollowAction.commit( + usersToAdd = refreshed.newUsers, + iAccount = iAccount, + relayManager = relayManager, + cache = cache, + ) + } + delay(400) + publishing = false + if (ok) onSuccess() else onFailure() + onDismiss() + } + }, + ) { + Text( + when { + publishing -> "Publishing…" + isFullyFollowed -> "Unfollow ${p.existingCount}" + else -> "Follow ${p.newCount}" + }, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !publishing) { + Text("Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt new file mode 100644 index 0000000000..60ed6c4164 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt @@ -0,0 +1,162 @@ +/* + * 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.followpacks.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.height +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.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.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.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor +import com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +/** + * Search-all view for follow packs. + * Search matches across: title, description, creator name/npub, `t` tag. + */ +@Composable +fun FollowPackBrowseAllScreen( + state: FollowPacksState, + cache: DesktopLocalCache, + onOpenPack: (String) -> Unit, + onBack: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + var query by remember { mutableStateOf("") } + val packs by state.allPacks.collectAsState() + + @Suppress("UNUSED_VARIABLE") + val metadataVersion by cache.metadataVersion.collectAsState() + + val results = + remember(packs, query, metadataVersion) { + if (query.isBlank()) packs else filter(packs, query.trim(), cache) + } + + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + if (onBack != null) { + IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.padding(4.dp)) + } + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search packs by title, creator, or tag…") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + + Spacer(Modifier.height(12.dp)) + Text( + text = "${results.size} ${if (results.size == 1) "pack" else "packs"}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + + if (results.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = + if (query.isBlank()) { + "No packs in cache yet. They'll appear as relays send them." + } else { + "No packs match \"$query\"" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = results, key = { FollowPackEditor.aTag(it) }) { pack -> + FollowPackRow( + pack = pack, + cache = cache, + onClick = { onOpenPack(FollowPackEditor.aTag(pack)) }, + ) + } + } + } + } +} + +private fun filter( + packs: List, + raw: String, + cache: DesktopLocalCache, +): List { + val q = raw.lowercase() + val asHex: HexKey? = decodePublicKeyAsHexOrNull(raw) + return packs.filter { pack -> + val title = pack.title()?.lowercase().orEmpty() + val desc = pack.description()?.lowercase().orEmpty() + val tags = pack.hashtags().map { it.lowercase() } + val creator = cache.getUserIfExists(pack.pubKey) + val creatorName = creator?.toBestDisplayName()?.lowercase().orEmpty() + val creatorNpub = creator?.pubkeyNpub()?.lowercase().orEmpty() + + title.contains(q) || + desc.contains(q) || + tags.any { it.contains(q) } || + creatorName.contains(q) || + (q.length >= 4 && creatorNpub.startsWith(q)) || + (asHex != null && pack.pubKey.equals(asHex, ignoreCase = true)) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt new file mode 100644 index 0000000000..a3c78d9a1e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt @@ -0,0 +1,361 @@ +/* + * 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.followpacks.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent + +/** + * Stack of overlapping circular avatars for pack members. + * Shows up to [maxVisible] avatars; remainder is "+N" badge. + */ +@Composable +fun FollowPackAvatarStack( + memberHexes: List, + cache: DesktopLocalCache, + modifier: Modifier = Modifier, + avatarSize: androidx.compose.ui.unit.Dp = 32.dp, + maxVisible: Int = 5, +) { + @Suppress("UNUSED_VARIABLE") + val metadataVersion by cache.metadataVersion.collectAsState() + + val visible = remember(memberHexes, maxVisible) { memberHexes.take(maxVisible) } + val remaining = (memberHexes.size - visible.size).coerceAtLeast(0) + val overlap = (avatarSize.value * 0.35f).dp + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + visible.forEachIndexed { index, hex -> + val pictureUrl = remember(hex, metadataVersion) { cache.getUserIfExists(hex)?.profilePicture() } + Box( + modifier = + Modifier + .offset(x = if (index == 0) 0.dp else (-overlap) * index) + .size(avatarSize + 4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + UserAvatar( + userHex = hex, + pictureUrl = pictureUrl, + size = avatarSize, + contentDescription = "Pack member", + ) + } + } + if (remaining > 0) { + Box( + modifier = + Modifier + .offset(x = (-overlap) * visible.size) + .size(avatarSize + 4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(avatarSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+$remaining", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } +} + +/** + * Big hero card for the featured pack. Title + description + avatar stack + Follow-all CTA. + * + * [fullyFollowed]: when true, the action button flips to "Unfollow all". + */ +@Composable +fun FollowPackHeroCard( + pack: FollowListEvent, + cache: DesktopLocalCache, + onFollowAll: () -> Unit, + onShare: () -> Unit, + onOpenDetail: () -> Unit, + fullyFollowed: Boolean = false, + modifier: Modifier = Modifier, +) { + val title = pack.title()?.ifBlank { null } ?: "Untitled pack" + val description = pack.description()?.ifBlank { null } + val image = pack.image()?.ifBlank { null } + val memberCount = pack.follows().size + + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onOpenDetail), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(16.dp), + ) { + // Image header (AsyncImage when available, gradient fallback) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(140.dp) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background( + Color( + (title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(), + ), + ), + ) { + if (image != null) { + AsyncImage( + model = image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(140.dp), + ) + } + } + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (description != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(12.dp)) + FollowPackAvatarStack( + memberHexes = pack.followIds(), + cache = cache, + avatarSize = 32.dp, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "$memberCount ${if (memberCount == 1) "member" else "members"}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (fullyFollowed) { + OutlinedButton(onClick = onFollowAll) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Unfollow all") + } + } else { + Button(onClick = onFollowAll) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Follow all") + } + } + OutlinedButton(onClick = onShare) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Share") + } + } + } + } +} + +/** + * Horizontal chip rail for hashtags pulled from a featured pack. + * Click toggles a filter on the gallery below. + */ +@Composable +fun FollowPackChipRail( + tags: List, + selected: String?, + onSelect: (String?) -> Unit, + modifier: Modifier = Modifier, +) { + if (tags.isEmpty()) return + LazyRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(horizontal = 4.dp), + ) { + items(tags) { tag -> + FilterChip( + selected = tag == selected, + onClick = { onSelect(if (tag == selected) null else tag) }, + label = { Text("#$tag") }, + ) + } + } +} + +/** + * Single thumbnail row used in galleries and browse-all lists. + */ +@Composable +fun FollowPackRow( + pack: FollowListEvent, + cache: DesktopLocalCache, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val title = pack.title()?.ifBlank { null } ?: "Untitled pack" + val description = pack.description()?.ifBlank { null } + val image = pack.image()?.ifBlank { null } + val memberCount = pack.follows().size + + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = + Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + Color( + (title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(), + ), + ), + ) { + if (image != null) { + AsyncImage( + model = image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(56.dp), + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + FollowPackAvatarStack( + memberHexes = pack.followIds(), + cache = cache, + avatarSize = 20.dp, + maxVisible = 4, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "$memberCount ${if (memberCount == 1) "member" else "members"}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt new file mode 100644 index 0000000000..ae07b1d85d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt @@ -0,0 +1,396 @@ +/* + * 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.followpacks.ui + +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.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 coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor +import com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState +import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import kotlinx.coroutines.launch + +/** + * Read-only pack detail screen. Opened as overlay when user clicks a pack + * in Discover or Browse-all. + * + * Shows: pack hero (title, desc, image, member count), Follow all + Share + + * Open profile actions, scrollable member list. + */ +@Composable +fun FollowPackDetailScreen( + addressTag: String, + state: FollowPacksState, + cache: DesktopLocalCache, + iAccount: DesktopIAccount, + relayManager: RelayConnectionManager, + onBack: () -> Unit, + onNavigateToProfile: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val allPacks by state.allPacks.collectAsState() + + @Suppress("UNUSED_VARIABLE") + val metadataVersion by cache.metadataVersion.collectAsState() + + val pack: FollowListEvent? = + remember(addressTag, allPacks) { + allPacks.firstOrNull { FollowPackEditor.aTag(it) == addressTag } + } + + if (pack == null) { + EmptyOrLoadingDetail(onBack = onBack) + return + } + + // Prefetch metadata for members so avatars + names populate. + DisposableEffect(pack.id) { + val memberHexes = pack.followIdSet().toList().take(200) + val handle = relayManager.subscribeMetadataFor(memberHexes, cache) + onDispose { handle() } + } + + val followedAuthors by iAccount.kind3FollowList.flow.collectAsState() + val followedSet = followedAuthors.authors + + var showFollowAll by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Column(modifier = modifier.fillMaxSize()) { + // Top bar with back button + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(4.dp)) + Text( + text = pack.title()?.ifBlank { null } ?: "Untitled pack", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item("hero") { + val fullyFollowed = + pack.followIds().isNotEmpty() && + pack.followIds().all { it == iAccount.pubKey || it in followedSet } + PackHeroDetail( + pack = pack, + cache = cache, + onFollowAll = { showFollowAll = true }, + onShare = { copyToClipboardLocal(FollowPackEditor.toShareUri(pack)) }, + fullyFollowed = fullyFollowed, + ) + } + item("members-header") { + Text( + text = "MEMBERS (${pack.follows().size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + ) + } + items(items = pack.followIds(), key = { it }) { hex -> + MemberRow( + pubkeyHex = hex, + cache = cache, + isFollowed = hex in followedSet, + onOpenProfile = { onNavigateToProfile(hex) }, + onFollow = { + scope.launch { + val user = cache.getOrCreateUser(hex) + val newCl = iAccount.kind3FollowList.follow(user) + cache.consume(newCl, null, wasVerified = false) + relayManager.broadcastToAll(newCl) + } + }, + onUnfollow = { + scope.launch { + val user = cache.getOrCreateUser(hex) + val newCl = iAccount.kind3FollowList.unfollow(user) ?: return@launch + cache.consume(newCl, null, wasVerified = false) + relayManager.broadcastToAll(newCl) + } + }, + ) + } + } + } + + if (showFollowAll) { + FollowAllConfirmDialog( + pack = pack, + iAccount = iAccount, + cache = cache, + relayManager = relayManager, + onDismiss = { showFollowAll = false }, + ) + } +} + +@Composable +private fun PackHeroDetail( + pack: FollowListEvent, + cache: DesktopLocalCache, + onFollowAll: () -> Unit, + onShare: () -> Unit, + fullyFollowed: Boolean, +) { + val title = pack.title()?.ifBlank { null } ?: "Untitled pack" + val description = pack.description()?.ifBlank { null } + val image = pack.image()?.ifBlank { null } + val memberCount = pack.follows().size + + Column( + modifier = + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(16.dp), + ).padding(16.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(160.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + Color( + (title.hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(), + ), + ), + ) { + if (image != null) { + AsyncImage( + model = image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(160.dp), + ) + } + } + Spacer(Modifier.height(16.dp)) + Text(text = title, style = MaterialTheme.typography.headlineSmall) + if (description != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(12.dp)) + FollowPackAvatarStack( + memberHexes = pack.followIds(), + cache = cache, + avatarSize = 36.dp, + maxVisible = 8, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "$memberCount ${if (memberCount == 1) "member" else "members"}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (fullyFollowed) { + OutlinedButton(onClick = onFollowAll) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Unfollow all") + } + } else { + Button(onClick = onFollowAll, enabled = memberCount > 0) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Follow all") + } + } + OutlinedButton(onClick = onShare) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Share") + } + } + } +} + +@Composable +private fun MemberRow( + pubkeyHex: HexKey, + cache: DesktopLocalCache, + isFollowed: Boolean, + onOpenProfile: () -> Unit, + onFollow: () -> Unit, + onUnfollow: () -> Unit, +) { + @Suppress("UNUSED_VARIABLE") + val metadataVersion by cache.metadataVersion.collectAsState() + + val user = remember(pubkeyHex, metadataVersion) { cache.getUserIfExists(pubkeyHex) } + val displayName = user?.toBestDisplayName() ?: (pubkeyHex.take(12) + "…") + val avatarUrl = user?.profilePicture() + + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onOpenProfile) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserAvatar( + userHex = pubkeyHex, + pictureUrl = avatarUrl, + size = 36.dp, + contentDescription = displayName, + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val nip05 = user?.metadataOrNull()?.nip05() + if (!nip05.isNullOrBlank()) { + Text( + text = nip05, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (isFollowed) { + OutlinedButton(onClick = onUnfollow) { + Text("Unfollow") + } + } else { + Button(onClick = onFollow) { + Text("Follow") + } + } + } +} + +@Composable +private fun EmptyOrLoadingDetail(onBack: () -> Unit) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(20.dp), + ) + } + } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Pack not found in cache.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt new file mode 100644 index 0000000000..f9b1efc816 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt @@ -0,0 +1,283 @@ +/* + * 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.followpacks.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + +/** + * Horizontal row of up to 3 recent notes from authors in [memberHexes]. + * Each card shows avatar + name + handle + content + first image (if any). + */ +@Composable +fun FromThePackFeed( + memberHexes: Set, + cache: DesktopLocalCache, + relayManager: RelayConnectionManager, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + modifier: Modifier = Modifier, + maxNotes: Int = 3, +) { + @Suppress("UNUSED_VARIABLE") + val metadataVersion by cache.metadataVersion.collectAsState() + + DisposableEffect(memberHexes.hashCode()) { + if (memberHexes.isEmpty()) { + onDispose { } + } else { + val subId = "pack-notes-${memberHexes.hashCode()}" + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = memberHexes.toList().take(50), + limit = 50, + ) + relayManager.subscribe( + subId, + listOf(filter), + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + cache.consume(event, relay) + } + }, + ) + val metaHandle = relayManager.subscribeMetadataFor(memberHexes.toList().take(50), cache) + onDispose { + relayManager.unsubscribe(subId) + metaHandle() + } + } + } + + val notes = + remember(memberHexes, metadataVersion) { + val out = mutableListOf() + cache.notes.forEach { _, note -> + val ev = note.event + if (ev is TextNoteEvent && ev.pubKey in memberHexes) { + val author = cache.getUserIfExists(ev.pubKey) + out.add( + NotePreview( + id = ev.id, + pubKey = ev.pubKey, + createdAt = ev.createdAt, + content = ev.content, + imageUrl = firstImageUrl(ev.content), + authorName = author?.toBestDisplayName(), + authorNip05 = author?.metadataOrNull()?.nip05(), + authorAvatarUrl = author?.profilePicture(), + ), + ) + } + } + out.sortByDescending { it.createdAt } + out.take(maxNotes) + } + + if (notes.isEmpty()) { + Box( + modifier = modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Loading recent notes from this pack…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + notes.forEach { note -> + NoteCardPretty( + note = note, + onOpenThread = { onNavigateToThread(note.id) }, + onOpenProfile = { onNavigateToProfile(note.pubKey) }, + modifier = Modifier.weight(1f), + ) + } + // If fewer than maxNotes results, pad with empty Spacers so cards keep their proportional width. + repeat(maxNotes - notes.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +private data class NotePreview( + val id: String, + val pubKey: HexKey, + val createdAt: Long, + val content: String, + val imageUrl: String?, + val authorName: String?, + val authorNip05: String?, + val authorAvatarUrl: String?, +) + +@Composable +private fun NoteCardPretty( + note: NotePreview, + onOpenThread: () -> Unit, + onOpenProfile: () -> Unit, + modifier: Modifier = Modifier, +) { + val name = note.authorName ?: (note.pubKey.take(12) + "…") + val handle = note.authorNip05?.removePrefix("_@") + val relative = relativeTime(note.createdAt) + Column( + modifier = + modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onOpenThread) + .padding(12.dp), + ) { + // Author row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.clickable(onClick = onOpenProfile), + ) { + UserAvatar( + userHex = note.pubKey, + pictureUrl = note.authorAvatarUrl, + size = 36.dp, + contentDescription = name, + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (handle != null) { + Text( + text = "@$handle • $relative", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = relative, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Spacer(Modifier.height(8.dp)) + Text( + text = note.content.trim(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (note.imageUrl != null) 3 else 6, + overflow = TextOverflow.Ellipsis, + ) + if (note.imageUrl != null) { + Spacer(Modifier.height(10.dp)) + AsyncImage( + model = note.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 200.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } + } +} + +// --- helpers --- + +private val IMAGE_URL_REGEX = + Regex( + "https?://\\S+\\.(jpg|jpeg|png|webp|gif|bmp)(\\?[^\\s]*)?", + RegexOption.IGNORE_CASE, + ) + +private fun firstImageUrl(content: String): String? = IMAGE_URL_REGEX.find(content)?.value + +private fun relativeTime(createdAtSec: Long): String { + val now = System.currentTimeMillis() / 1000L + val delta = (now - createdAtSec).coerceAtLeast(0) + return when { + delta < 60 -> "now" + delta < 3600 -> "${delta / 60}m" + delta < 86_400 -> "${delta / 3600}h" + delta < 30L * 86_400 -> "${delta / 86_400}d" + delta < 365L * 86_400 -> "${delta / (30L * 86_400)}mo" + else -> "${delta / (365L * 86_400)}y" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt new file mode 100644 index 0000000000..ae82ca7876 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt @@ -0,0 +1,238 @@ +/* + * 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.followpacks.ui + +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.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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress as NAddressEntity + +/** + * Inline rich card for `naddr` references to kind 39089 (Follow Pack) events + * embedded inside notes. Renders avatar stack + Follow-all CTA. + * + * If the addressable event isn't in the cache, subscribes for it and shows a + * skeleton until it arrives. + */ +@Composable +fun RenderFollowPackCard( + address: NAddressEntity, + cache: DesktopLocalCache, + iAccount: DesktopIAccount?, + relayManager: RelayConnectionManager, + onOpenPack: (String) -> Unit, + modifier: Modifier = Modifier, +) { + @Suppress("UNUSED_VARIABLE") + val packVersion by cache.followPackVersion.collectAsState() + + val aTag = remember(address) { Address.assemble(address.kind, address.author, address.dTag) } + val pack: FollowListEvent? = + remember(packVersion, aTag) { + cache.snapshotFollowPacks().firstOrNull { FollowPackEditor.aTag(it) == aTag } + } + + // If not in cache, kick off a one-shot subscription + if (pack == null) { + DisposableEffect(aTag) { + val subId = "naddr-fetch-${aTag.hashCode()}" + val filter = + Filter( + kinds = listOf(address.kind), + authors = listOf(address.author), + tags = mapOf("d" to listOf(address.dTag)), + limit = 1, + ) + relayManager.subscribe( + subId, + listOf(filter), + listener = + object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + cache.consume(event, relay) + } + }, + ) + onDispose { + relayManager.unsubscribe(subId) + } + } + PackCardSkeleton(modifier = modifier) + return + } + + if (pack.follows().isEmpty()) { + // Empty pack — render minimal state, no Follow-all CTA (gap C5) + Card( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = pack.title()?.ifBlank { null } ?: "Untitled pack", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Empty pack", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + return + } + + var showFollowAll by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + Color( + ((pack.title() ?: pack.id).hashCode() and 0x80FFFFFF.toInt()) or 0xFF608080.toInt(), + ), + ), + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = pack.title()?.ifBlank { null } ?: "Follow pack", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${pack.follows().size} people", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(8.dp)) + FollowPackAvatarStack( + memberHexes = pack.followIds(), + cache = cache, + avatarSize = 28.dp, + maxVisible = 6, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { onOpenPack(aTag) }) { Text("Open") } + if (iAccount != null) { + TextButton(onClick = { showFollowAll = true }) { + Text("Follow all") + } + } + } + } + } + + if (showFollowAll && iAccount != null) { + FollowAllConfirmDialog( + pack = pack, + iAccount = iAccount, + cache = cache, + relayManager = relayManager, + onDismiss = { showFollowAll = false }, + ) + } +} + +@Composable +private fun PackCardSkeleton(modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)), + ) + Spacer(Modifier.height(8.dp)) + Box( + modifier = + Modifier + .fillMaxWidth(0.6f) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)), + ) + } + } +} 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 ac49cdc847..758870cae7 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 @@ -124,6 +124,8 @@ fun DeckColumnType.category(): ScreenCategory = DeckColumnType.Search, is DeckColumnType.Hashtag, DeckColumnType.Bookmarks, + DeckColumnType.Discover, + DeckColumnType.FollowPacks, -> ScreenCategory.DISCOVERY DeckColumnType.MyProfile, @@ -166,6 +168,8 @@ val LAUNCHABLE_SCREENS: List = DeckColumnType.Notifications, DeckColumnType.Messages, DeckColumnType.GlobalFeed, + DeckColumnType.Discover, + DeckColumnType.FollowPacks, DeckColumnType.Reads, DeckColumnType.Drafts, DeckColumnType.Editor(), 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 a857721427..f4bcc23b4c 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 @@ -131,4 +131,6 @@ fun DeckColumnType.icon(): MaterialSymbol = is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article is DeckColumnType.Hashtag -> MaterialSymbols.Tag is DeckColumnType.CustomFeed -> MaterialSymbols.Tune + DeckColumnType.Discover -> MaterialSymbols.Explore + DeckColumnType.FollowPacks -> MaterialSymbols.Apps } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 9bb7ea8d41..ee85ce8122 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -227,6 +227,8 @@ fun DeckColumnContainer( onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, onNavigateToRelays = onNavigateToRelays, + onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) }, + onNavigateToPackBrowseAll = { navState.push(DesktopScreen.FollowPackBrowseAll) }, ) // Overlay with slide animation @@ -284,6 +286,7 @@ fun DeckColumnContainer( relayManager = relayManager, localCache = localCache, account = account, + iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, highlightStore = highlightStore, @@ -294,6 +297,7 @@ fun DeckColumnContainer( onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) }, onBack = { navState.pop() }, ) } @@ -327,6 +331,8 @@ internal fun RootContent( onNavigateToEditor: (String?) -> Unit = {}, onNavigateToRelays: () -> Unit = {}, onOpenFeedsDrawer: () -> Unit = {}, + onNavigateToPack: (String) -> Unit = {}, + onNavigateToPackBrowseAll: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -575,6 +581,37 @@ internal fun RootContent( onZapFeedback = onZapFeedback, ) } + + DeckColumnType.Discover -> { + val followPacks = LocalFollowPacksState.current + if (followPacks != null) { + com.vitorpamplona.amethyst.desktop.followpacks.ui + .DiscoverScreen( + state = followPacks, + cache = localCache, + iAccount = iAccount, + relayManager = relayManager, + onOpenPack = { aTag -> onNavigateToPack(aTag) }, + onOpenBrowseAll = { onNavigateToPackBrowseAll() }, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + } + + DeckColumnType.FollowPacks -> { + val followPacks = LocalFollowPacksState.current + if (followPacks != null) { + com.vitorpamplona.amethyst.desktop.followpacks.ui + .FollowPackBrowseAllScreen( + state = followPacks, + cache = localCache, + onOpenPack = { aTag -> onNavigateToPack(aTag) }, + onBack = null, + ) + } + } } } @@ -584,6 +621,7 @@ internal fun OverlayContent( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn, + iAccount: DesktopIAccount? = null, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, highlightStore: DesktopHighlightStore? = null, @@ -594,6 +632,7 @@ internal fun OverlayContent( onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToArticle: (String) -> Unit = {}, + onNavigateToPack: (String) -> Unit = {}, onBack: () -> Unit, ) { when (screen) { @@ -656,6 +695,35 @@ internal fun OverlayContent( ) } + is DesktopScreen.FollowPackDetail -> { + val followPacks = LocalFollowPacksState.current + if (followPacks != null && iAccount != null) { + com.vitorpamplona.amethyst.desktop.followpacks.ui + .FollowPackDetailScreen( + addressTag = screen.addressTag, + state = followPacks, + cache = localCache, + iAccount = iAccount, + relayManager = relayManager, + onBack = onBack, + onNavigateToProfile = onNavigateToProfile, + ) + } + } + + is DesktopScreen.FollowPackBrowseAll -> { + val followPacks = LocalFollowPacksState.current + if (followPacks != null) { + com.vitorpamplona.amethyst.desktop.followpacks.ui + .FollowPackBrowseAllScreen( + state = followPacks, + cache = localCache, + onOpenPack = { aTag -> onNavigateToPack(aTag) }, + onBack = onBack, + ) + } + } + else -> { androidx.compose.material3.Text( "Unsupported screen type", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index 071ad2a56a..dff92c6d99 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -77,6 +77,10 @@ sealed class DeckColumnType { val feedEmoji: String = "", ) : DeckColumnType() + object Discover : DeckColumnType() + + object FollowPacks : DeckColumnType() + fun title(): String = when (this) { HomeFeed -> "Home" @@ -99,6 +103,8 @@ sealed class DeckColumnType { is Thread -> "Thread" is Hashtag -> "#$tag" is CustomFeed -> feedName.ifEmpty { "Feed" } + Discover -> "Discover" + FollowPacks -> "Follow Packs" } fun typeKey(): String = @@ -123,6 +129,8 @@ sealed class DeckColumnType { is Thread -> "thread" is Hashtag -> "hashtag" is CustomFeed -> "custom_feed" + Discover -> "discover" + FollowPacks -> "follow_packs" } } 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 1d0543c9c9..94fd69ce30 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 @@ -99,6 +99,7 @@ private data class NavItem( private val NAV_ITEMS = listOf( NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home), + NavItem(DeckColumnType.Discover, "Discover", MaterialSymbols.Explore), NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search), NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail), NavItem(DeckColumnType.Wallet, "Wallet", MaterialSymbols.AccountBalanceWallet), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 1fd4284345..63b60c7607 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -308,6 +308,8 @@ class DeckState( "thread" -> param?.let { DeckColumnType.Thread(it) } "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } "custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) } + "discover" -> DeckColumnType.Discover + "follow_packs" -> DeckColumnType.FollowPacks else -> null } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt index 9e57736ae5..299e5afa5b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt @@ -95,3 +95,8 @@ val LocalRelayListMutator = compositionLocalOf { null } + +val LocalFollowPacksState = + compositionLocalOf { + null + } 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 65517a0d8b..2a9c3c94e3 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 @@ -134,6 +134,8 @@ fun SinglePaneLayout( onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) }, onOpenFeedsDrawer = onOpenFeedsDrawer, + onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) }, + onNavigateToPackBrowseAll = { navState.push(DesktopScreen.FollowPackBrowseAll) }, ) AnimatedContent( targetState = currentOverlay, @@ -159,6 +161,7 @@ fun SinglePaneLayout( relayManager = relayManager, localCache = localCache, account = account, + iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, highlightStore = highlightStore, @@ -169,6 +172,7 @@ fun SinglePaneLayout( onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) }, onBack = { navState.pop() }, ) } 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 a102968961..07f0b7ac51 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 @@ -439,6 +439,29 @@ private fun RenderBechSegment( localCache: DesktopLocalCache?, callbacks: RichTextCallbacks, ) { + // Try NAddress first — kind 39089 (Follow Pack) renders as a rich card. + val naddr = + remember(segment.segmentText) { + com.vitorpamplona.quartz.nip19Bech32.entities + .NAddress + .parse(segment.segmentText) + } + if (naddr != null && naddr.kind == com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent.KIND) { + val followPacks = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState.current + val relayManager = com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager.current + if (localCache != null && relayManager != null && followPacks != null) { + com.vitorpamplona.amethyst.desktop.followpacks.ui + .RenderFollowPackCard( + address = naddr, + cache = localCache, + iAccount = null, + relayManager = relayManager, + onOpenPack = { /* no nav callback available in rich-text context */ }, + ) + return + } + } + val resolved = remember(segment.segmentText, localCache) { resolveBech32(segment.segmentText, localCache) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt index d1341702eb..d9288f5936 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.remove @@ -68,6 +69,8 @@ class FollowListEvent( fun followIdSet() = tags.followIdSet() + fun hashtags(): List = tags.mapNotNull(HashtagTag::parse) + companion object { const val KIND = 39089