diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt index bd7e82f54f..4badbacef1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accessibility_navigate import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar @@ -51,6 +53,12 @@ import org.jetbrains.compose.resources.stringResource * @param badge Optional overlay drawn on top of the avatar (bottom-right * by convention). Used by Desktop for the WoT trust-score chip; Android * call sites leave it null. Forwarded to [UserAvatar]. + * + * Loads the user's kind-0 metadata only while the card is composed via + * [observeUserInfo], so a search-results list only fetches metadata for the + * users currently on screen (coalesced into the shared finder's batched REQs). + * Requires [LocalUserFinder]/[LocalUserFinderAccount] in scope — provided at + * the Desktop logged-in roots; this card is Desktop-only. */ @Composable fun UserSearchCard( @@ -59,6 +67,8 @@ fun UserSearchCard( modifier: Modifier = Modifier, badge: @Composable (BoxScope.() -> Unit)? = null, ) { + val metadata by observeUserInfo(user) + Card( modifier = modifier @@ -76,7 +86,7 @@ fun UserSearchCard( ) { UserAvatar( userHex = user.pubkeyHex, - pictureUrl = user.profilePicture(), + pictureUrl = metadata?.info?.picture ?: user.profilePicture(), size = 40.dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), badge = badge, @@ -84,11 +94,11 @@ fun UserSearchCard( Column(modifier = Modifier.weight(1f)) { Text( - user.toBestDisplayName(), + metadata?.info?.bestName() ?: user.toBestDisplayName(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) - val nip05 = user.metadataOrNull()?.nip05() + val nip05 = metadata?.info?.nip05 ?: user.metadataOrNull()?.nip05() if (nip05 != null) { Text( nip05, 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 3c5faabd88..704c5f1a2c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,8 +81,11 @@ import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSetti import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner +import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady import com.vitorpamplona.amethyst.commons.wot.LocalWoTService @@ -1470,6 +1473,9 @@ private fun AppInner( LocalNamecoinService provides namecoinService, LocalSpamExemptKeys provides spamExemptKeys, com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + LocalUserFinder provides subscriptionsCoordinator.userFinder, + LocalUserFinderAccount provides iAccount, + LocalEventFinder provides subscriptionsCoordinator.eventFinder, ) { val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() Column(modifier = Modifier.fillMaxSize()) { @@ -2114,6 +2120,9 @@ fun MainContent( LocalRelayCategories provides relayCategories, LocalBlossomServers provides iAccount.blossomServerList.flow, com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + LocalUserFinder provides subscriptionsCoordinator.userFinder, + LocalUserFinderAccount provides iAccount, + LocalEventFinder provides subscriptionsCoordinator.eventFinder, com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost provides snackbarHostState, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 3d9b28bbb2..c045e74df5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListSt import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager @@ -64,6 +65,7 @@ import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope @@ -92,11 +94,42 @@ class DesktopIAccount( private val scope: CoroutineScope, private val accountRelays: DesktopAccountRelays? = null, val dmInboxResolver: DmInboxRelayResolver? = null, -) : IAccount { +) : IAccount, + UserFinderAccount { override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME) override val pubKey: String = accountState.pubKeyHex + // UserFinderAccount — Desktop's relay-hint view for the shared per-user + // metadata subscription layer. Desktop has no separate indexer/search relay + // lists nor a NIP-85 trust provider, so it routes discovery through its + // connected relays + NIP-65 outbox and degrades trust/reports to null/empty + // (contact-card ranking + report loading are best-effort here — see + // UserFinderAccount). + override val userFinderPubkeyHex: HexKey get() = pubKey + + override fun indexRelays(): Set = relayManager.connectedRelays.value + + override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + relayManager.connectedRelays.value + + override fun searchRelays(): Set = relayManager.connectedRelays.value + + // Desktop has no merged follow/mine/search relay-list subsystem; route + // missing-event discovery through the connected relays (same degrade path + // as the other hints above). + override fun followPlusAllMineWithSearchRelays(): Set = relayManager.connectedRelays.value + + override fun commonRelays(): Set = relayManager.connectedRelays.value + + override fun cardHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + + override fun trustProvider(): ServiceProviderTag? = null + + // Desktop has no NIP-85 rank/follower providers wired (same as trustProvider). + override fun followerCountProvider(): ServiceProviderTag? = null + + override fun declaredFollowsByOutboxRelay(): Map> = emptyMap() + // ----- State Classes (pin important notes via strong refs for GC retention) ----- val oldBookmarkState = OldBookmarkListState(signer, localCache, scope) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 3a315d6507..78c1cffa71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.desktop.subscriptions import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher @@ -32,6 +34,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -83,6 +86,32 @@ class DesktopRelaySubscriptionsCoordinator( private val indexRelays: Set, private val localCache: DesktopLocalCache, ) { + /** + * Tracks relays that refuse to connect, so the shared user-finder skips + * them when picking discovery relays. Self-registers as a client listener. + */ + private val failureTracker = RelayOfflineTracker(client) + + /** + * The shared, composition-scoped per-user metadata subscription assembler + * (moved to commons). Desktop composables reach it via [LocalUserFinder] + * (provided in Main.kt) and subscribe per visible user through + * `observeUserPicture(user)` / `observeUserInfo(user)`, so metadata loads + * only for on-screen users. Coalesces every subscribed user into batched + * REQs — no per-avatar REQ storm. + */ + val userFinder = UserFinderFilterAssembler(client, localCache, failureTracker) + + /** + * The shared, composition-scoped per-note event subscription assembler + * (reactions / zaps / reposts / replies, moved to commons). Desktop note + * rows reach it via [LocalEventFinder] (provided in Main.kt) and subscribe + * per visible note through `EventFinderFilterAssemblerSubscription(note)`, so + * interactions load only for on-screen notes. Composes [userFinder] to + * resolve authors of not-yet-cached addressable notes. + */ + val eventFinder = EventFinderFilterAssembler(client, localCache, userFinder) + // Rate limiter: 20 requests per second to avoid flooding relays private val rateLimiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 16696692b5..3c77deba9c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -95,6 +95,10 @@ import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.search.QuerySerializer @@ -268,6 +272,20 @@ private fun FeedNoteCardBody( myPubKeyHex: String? = null, onFollow: ((String) -> Unit)? = null, ) { + // Load this note author's metadata (kind 0 + relay lists) only while this + // card is composed — i.e. on or near screen. The shared commons finder + // coalesces every visible author into batched REQs, giving per-row, + // visibility-scoped metadata loading that matches Android's model. + val cardAuthor = note.author + if (cardAuthor != null) { + UserFinderFilterAssemblerSubscription(cardAuthor) + } + + // Load this note's interactions (reactions / zaps / reposts / replies) only + // while the card is composed — the per-note counterpart to the author + // subscription above, coalesced into batched REQs by the shared event finder. + EventFinderFilterAssemblerSubscription(note) + if (event is PollEvent) { DesktopPollCard( note = note, @@ -751,17 +769,19 @@ fun FeedScreen( } } - // Viewport-aware metadata loading: only fetch for visible notes + buffer - // Uses snapshotFlow to avoid per-frame recomposition from scroll observation + // Fast first-paint warm-up: one batched kind-0 REQ straight to the index + // relays for the first visible authors. The per-row UserFinder subscriptions + // (in FeedNoteCardBody) are the source of truth — they do NIP-65 outbox + // routing and tear down off-screen — but their two-hop discovery is slower to + // first paint, so this immediate batch fills names/avatars instantly. Also + // prefetches reactions + referenced (repost/quote) notes for the initial set. LaunchedEffect(feedState, subscriptionsCoordinator) { if (subscriptionsCoordinator == null || feedState !is FeedState.Loaded) return@LaunchedEffect - val loadedFeed = feedState as FeedState.Loaded - // Initial load: batch metadata for first visible notes immediately val initialNotes = viewModel.feedState.visibleNotes().take(30) if (initialNotes.isNotEmpty()) { val authors = initialNotes.mapNotNull { it.author?.pubkeyHex }.distinct() - subscriptionsCoordinator.loadMetadataBatched(authors) + if (authors.isNotEmpty()) subscriptionsCoordinator.loadMetadataBatched(authors) subscriptionsCoordinator.loadMetadataForNotes(initialNotes) } } @@ -988,7 +1008,11 @@ fun FeedScreen( val loadedState by state.feed.collectAsState() val lazyListState = homeFeedLazyListState - // Viewport-aware scroll observation: fetch metadata for newly visible notes + // Fast-path warm-up on scroll: batch a kind-0 REQ to index + // relays for authors entering the viewport (+10 buffer), so + // names/avatars paint immediately. Complementary to the per-row + // FeedNoteCardBody subscriptions, which remain the source of + // truth (outbox routing + off-screen teardown). LaunchedEffect(lazyListState, loadedState) { if (subscriptionsCoordinator == null) return@LaunchedEffect val feedList = loadedState.list @@ -999,7 +1023,7 @@ fun FeedScreen( if (info.visibleItemsInfo.isEmpty()) return@snapshotFlow -1 to -1 info.visibleItemsInfo.first().index to info.visibleItemsInfo.last().index }.distinctUntilChanged() - .debounce(500) + .debounce(300) .collect { (first, last) -> if (first < 0) return@collect val from = (first - 10).coerceAtLeast(0) @@ -1974,15 +1998,9 @@ private fun ExpandedNoteContent( // Get reply notes from cache — recompute when replies change val replyNotes = remember(repliesState) { note.replies.sortedByDescending { it.createdAt() } } - // Load metadata for reply authors - LaunchedEffect(replyNotes, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && replyNotes.isNotEmpty()) { - val authors = replyNotes.mapNotNull { it.event?.pubKey }.distinct() - if (authors.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataBatched(authors) - } - } - } + // Reply-author metadata (kind 0) is loaded per-row: each CommentItem below + // opens its own composition-scoped observeUser* subscription, so metadata + // loads for on-screen replies only and tears down when the thread closes. Column(modifier = Modifier.padding(top = 8.dp)) { // Comments card @@ -2022,24 +2040,30 @@ private fun ExpandedNoteContent( replyNotes.take(5).forEachIndexed { index, replyNote -> val replyEvent = replyNote.event val flowSet = remember(replyNote) { replyNote.flow() } - val metadataState by flowSet.metadata.stateFlow.collectAsState() val reactionsState by flowSet.reactions.stateFlow.collectAsState() val zapsState by flowSet.zaps.stateFlow.collectAsState() DisposableEffect(replyNote) { onDispose { replyNote.clearFlow() } } - val author = - remember(replyEvent?.pubKey, metadataState) { - replyEvent?.pubKey?.let { localCache.getUserIfExists(it) } - } + // Load this reply's own interactions (reactions/zaps) only + // while the comment row is composed. + EventFinderFilterAssemblerSubscription(replyNote) + + // Load + observe this reply author's metadata only while the + // comment row is composed; observeUser* both subscribes and + // drives recomposition when kind-0 arrives. + val replyAuthorPubKey = replyEvent?.pubKey + val author = remember(replyAuthorPubKey, localCache) { replyAuthorPubKey?.let { localCache.getOrCreateUser(it) } } + val authorName = author?.let { observeUserName(it).value } + val authorPicture = author?.let { observeUserPicture(it).value } val reactionCount = remember(reactionsState) { replyNote.countReactions() } val zapAmount = remember(zapsState) { replyNote.zapsAmount } CommentItem( - authorName = author?.toBestDisplayName() ?: replyEvent?.pubKey?.take(8) ?: "", + authorName = authorName ?: replyAuthorPubKey?.take(8) ?: "", authorHandle = author?.pubkeyNpub()?.take(16)?.let { "@$it..." } ?: "", - authorAvatarUrl = author?.profilePicture(), - authorPubKeyHex = replyEvent?.pubKey ?: "", + authorAvatarUrl = authorPicture, + authorPubKeyHex = replyAuthorPubKey ?: "", content = replyEvent?.content ?: "", timeAgo = (replyEvent?.createdAt ?: 0L).toTimeAgo(), reactionCount = reactionCount, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index be0adf4ad5..4bdb8f1083 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationK import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings import com.vitorpamplona.amethyst.commons.moderation.notifications.nowEpochSeconds +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -654,10 +656,11 @@ private fun AggregateCard( horizontalArrangement = Arrangement.spacedBy((-4).dp), ) { reactorPubKeys.take(5).forEach { pk -> - val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + val user = remember(pk, localCache) { localCache.getOrCreateUser(pk) } + val picture by observeUserPicture(user) UserAvatar( userHex = pk, - pictureUrl = user?.profilePicture(), + pictureUrl = picture, size = 20.dp, modifier = Modifier.clickable { onNavigateToProfile(pk) }, ) @@ -694,15 +697,17 @@ private fun AggregateCard( .clickable { onNavigateToProfile(pk) } .padding(vertical = 3.dp), ) { - val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + val user = remember(pk, localCache) { localCache.getOrCreateUser(pk) } + val picture by observeUserPicture(user) + val name by observeUserName(user) UserAvatar( userHex = pk, - pictureUrl = user?.profilePicture(), + pictureUrl = picture, size = 22.dp, ) Spacer(Modifier.size(6.dp)) Text( - user?.toBestDisplayName() ?: pk.take(12), + name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -821,14 +826,17 @@ fun NotificationCard( // actual zap sender lives in the nested zap request. effectiveAuthorPubKey // returns the right one per kind. val pk = notification.effectiveAuthorPubKey - val user = remember(pk, metadataVersion, localCache) { localCache?.getUserIfExists(pk) } + // Load + observe the actor's metadata only while this card is composed. + // localCache is only null in previews/defaults; guard the observers so we + // never touch LocalUserFinder off the provider tree. + val user = remember(pk, localCache) { localCache?.getOrCreateUser(pk) } + val observedName = user?.let { observeUserName(it).value } + val observedPicture = user?.let { observeUserPicture(it).value } val displayName = - remember(user, metadataVersion, pk) { - user?.toBestDisplayName() - ?: pk.hexToByteArrayOrNull()?.toNpub()?.take(12) - ?: pk.take(12) - } - val pictureUrl = remember(user, metadataVersion) { user?.profilePicture() } + observedName + ?: pk.hexToByteArrayOrNull()?.toNpub()?.take(12) + ?: pk.take(12) + val pictureUrl = observedPicture val unread by remember(notification.timestamp, lastReadAt) { derivedStateOf { notification.timestamp > lastReadAt } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index ba976472ea..cbddc13117 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -349,19 +349,12 @@ fun SearchScreen( } } - // Load author metadata for incoming note results. NIP-50 search relays - // typically don't return kind-0 metadata alongside notes, and the - // subscription explicitly drops MetadataEvents anyway — so display name - // and avatar arrive only after we explicitly fetch them from index - // relays via the coordinator. - LaunchedEffect(noteResults, subscriptionsCoordinator) { - val coordinator = subscriptionsCoordinator ?: return@LaunchedEffect - if (noteResults.isEmpty()) return@LaunchedEffect - val authors = noteResults.map { it.pubKey }.distinct() - if (authors.isNotEmpty()) { - coordinator.loadMetadataBatched(authors) - } - } + // Note-result author metadata (kind 0) is no longer batch-fetched here. + // Each result renders through NoteCard, which opens its own composition-scoped + // UserFinderFilterAssembler subscription — so the author's metadata is fetched + // from index/outbox relays per on-screen result and torn down when scrolled off. + // (NIP-50 search relays don't return kind-0 and the subscription drops + // MetadataEvents; the per-row finder covers that gap.) // Fetch interactions (incl. kind-1018 poll responses) for poll results so their // tallies populate — NIP-50 search returns the polls but not their responses. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ecbd160bc1..dad4160970 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -194,12 +193,10 @@ fun ThreadScreen( onDispose { subId?.let { coordinator.releaseInteractions(it) } } } - // Load metadata for thread authors via coordinator - LaunchedEffect(threadNotes, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForNotes(threadNotes) - } - } + // Thread-author metadata + note interactions now load per row: each thread + // note renders through NoteCard, which opens composition-scoped UserFinder + + // EventFinder subscriptions. (requestInteractions above remains the thread's + // explicit interaction-refresh path.) // Fetch quoted notes referenced in thread content val quotedNoteIds = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt index 96a771e6d3..3a3b724d41 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt @@ -29,12 +29,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar @@ -59,6 +62,10 @@ fun ChatroomHeader( modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { + // Load + observe the partner's metadata only while this header is composed. + val picture by observeUserPicture(user) + val name by observeUserName(user) + Column( Modifier .fillMaxWidth() @@ -68,14 +75,14 @@ fun ChatroomHeader( Row(verticalAlignment = Alignment.CenterVertically) { UserAvatar( userHex = user.pubkeyHex, - pictureUrl = user.profilePicture(), + pictureUrl = picture, size = ChatSize34dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), ) Column(modifier = Modifier.padding(start = 10.dp)) { Text( - text = user.toBestDisplayName(), + text = name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, @@ -107,7 +114,12 @@ fun GroupChatroomHeader( modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { - val participants = users.joinToString(", ") { it.toBestDisplayName() } + // Load + observe each participant's metadata only while this header is + // composed, so names and the group-icon avatar update as kind-0 arrives. + // forEach is inline, so the @Composable observeUserName call is legal here. + val participantNames = mutableListOf() + users.forEach { participantNames.add(observeUserName(it).value) } + val participants = participantNames.joinToString(", ") Column( modifier = Modifier @@ -121,9 +133,10 @@ fun GroupChatroomHeader( Row(verticalAlignment = Alignment.CenterVertically) { // Show first user's avatar as the group icon users.firstOrNull()?.let { firstUser -> + val firstUserPicture by observeUserPicture(firstUser) UserAvatar( userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), + pictureUrl = firstUserPicture, size = ChatSize34dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 337b738e8b..3ada46ff4d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -320,9 +321,12 @@ private fun ConversationCard( val firstUser = item.users.firstOrNull() Box { if (firstUser != null) { + // Load + observe the conversation's primary user only while this + // row is composed (i.e. on screen in the list). + val firstUserPicture by observeUserPicture(firstUser) UserAvatar( userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), + pictureUrl = firstUserPicture, size = 40.dp, ) } else if (item.isGroup) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index adf0b732ca..27d6ae795a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -56,6 +56,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser @@ -63,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer @@ -112,6 +115,25 @@ fun NoteCard( replyContext: ReplyContext? = null, onNavigateToThread: ((String) -> Unit)? = null, ) { + // Load the author's metadata (kind 0) only while this card is composed — + // i.e. on/near screen. This one call covers every screen that renders + // through NoteCard (profile, thread, bookmarks, search); the shared commons + // finder coalesces all visible authors into batched REQs. + val noteCardCache = LocalDesktopCache.current + val noteCardAuthor = remember(note.pubKeyHex, noteCardCache) { noteCardCache?.getOrCreateUser(note.pubKeyHex) } + if (noteCardAuthor != null) { + UserFinderFilterAssemblerSubscription(noteCardAuthor) + } + + // Load this note's interactions (reactions / zaps / reposts / replies) only + // while the card is composed — covers every NoteCard surface (profile, thread, + // bookmarks, search, quoted embeds). Guarded on cache presence so previews + // (no LocalDesktopCache → no LocalEventFinder) don't hit the provider default. + val noteCardNote = remember(note.id, noteCardCache) { noteCardCache?.getNoteIfExists(note.id) } + if (noteCardNote != null) { + EventFinderFilterAssemblerSubscription(noteCardNote) + } + val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = remember(urls) {