mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
4d31cd7d25
commit
a867f9b2ca
+13
-3
@@ -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,
|
||||
|
||||
+34
-1
@@ -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)
|
||||
|
||||
+29
@@ -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,
|
||||
|
||||
+20
-12
@@ -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 }
|
||||
|
||||
+6
-13
@@ -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 =
|
||||
|
||||
+17
-4
@@ -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),
|
||||
)
|
||||
|
||||
+5
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user