feat: wire Desktop to the shared per-visible metadata + reaction finders (Phase 4)

Re-apply the Desktop adoption on top of the commons finders (now on upstream's model):
- DesktopIAccount implements UserFinderAccount (connected-relays/nip65 relay hints;
  trustProvider/followerCountProvider = null, declaredFollowsByOutboxRelay = emptyMap —
  Desktop has no NIP-85 subsystem). followerCountProvider() is the new getter this model needs.
- DesktopRelaySubscriptionsCoordinator builds userFinder + eventFinder (RelayOfflineTracker).
- Main.kt provides LocalUserFinder/LocalUserFinderAccount/LocalEventFinder at both logged-in
  composition roots.
- Per-visible adoption across feed (FeedNoteCardBody), NoteCard (profile/thread/bookmarks/
  search/quoted), DM headers + conversation list, UserSearchCard (observeUserInfo),
  NotificationsScreen, thread replies — plus the fast index-relay warm-up on the feed.

Metadata + reactions now load strictly per on-screen user/note on Desktop, coalesced into
batched REQs by the shared finders.

Green: :desktopApp compile, :amethyst compilePlayDebugKotlin, :commons verifyKmpPurity, spotless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-08-10 11:38:09 +03:00
co-authored by Claude Opus 4.8
parent 4d31cd7d25
commit a867f9b2ca
11 changed files with 207 additions and 65 deletions
@@ -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,
@@ -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,
@@ -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<NormalizedRelayUrl> = relayManager.connectedRelays.value
override fun outboxHomeRelays(): Set<NormalizedRelayUrl> = nip65RelayList.allFlowNoDefaults.value + relayManager.connectedRelays.value
override fun searchRelays(): Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl> = relayManager.connectedRelays.value
override fun commonRelays(): Set<NormalizedRelayUrl> = relayManager.connectedRelays.value
override fun cardHomeRelays(): Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl, Set<HexKey>> = emptyMap()
// ----- State Classes (pin important notes via strong refs for GC retention) -----
val oldBookmarkState = OldBookmarkListState(signer, localCache, scope)
@@ -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<NormalizedRelayUrl>,
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)
@@ -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,
@@ -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 }
@@ -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.
@@ -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 =
@@ -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<String>()
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),
)
@@ -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) {
@@ -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) {