Merge pull request #4191 from vitorpamplona/claude/sharp-keller-7itny0

refactor: one shared user avatar, shared user and event observers, score tags
This commit is contained in:
Vitor Pamplona
2026-09-24 08:31:45 -04:00
committed by GitHub
71 changed files with 1120 additions and 751 deletions
@@ -32,7 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
/**
* The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in
@@ -95,11 +95,11 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_no
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -2048,7 +2048,7 @@ class Account(
// blessed status. 40002 survives only as a read-compat tail from the
// 10002 -> 40001 -> 40002 migration, so we were the last active writer of a kind
// their clients no longer thread on. Reading 40002 stays supported (see
// [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply]).
// [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]).
//
// Attached media rides as URLs appended to the content.
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
@@ -70,8 +70,8 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_consent_deny_always
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_hide_event
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_not_now
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_show_event
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
@@ -97,13 +97,13 @@ import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_read_venue
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_send_dm
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_thread
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_thread_with
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -22,465 +22,152 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.textNoteModifications
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.sample
import com.vitorpamplona.amethyst.commons.relayClient.event.observeCommunityApprovalNeedStatus as sharedObserveCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNote as sharedObserveNote
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteAndMap as sharedObserveNoteAndMap
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEvent as sharedObserveNoteEvent
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEventAndMap as sharedObserveNoteEventAndMap
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEventAndMapNotNull as sharedObserveNoteEventAndMapNotNull
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteHasEvent as sharedObserveNoteHasEvent
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteMinichatReplyCount as sharedObserveNoteMinichatReplyCount
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteModifications as sharedObserveNoteModifications
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteOts as sharedObserveNoteOts
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteReactionCount as sharedObserveNoteReactionCount
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteReactions as sharedObserveNoteReactions
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteReferences as sharedObserveNoteReferences
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteReplies as sharedObserveNoteReplies
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteReplyCount as sharedObserveNoteReplyCount
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteRepostCount as sharedObserveNoteRepostCount
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteReposts as sharedObserveNoteReposts
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteRepostsBy as sharedObserveNoteRepostsBy
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteZaps as sharedObserveNoteZaps
/*
* Android overloads of the shared per-note observers in commons `relayClient/event`: they take the
* account and event-finder data source from [AccountViewModel] instead of the composition locals,
* so they also work under Activity roots that don't provide those locals.
*/
@Composable
fun observeNote(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<NoteState> = sharedObserveNote(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
/**
* [observeNote] without the relay half: watches LocalCache and asks no relay for the note.
*
* For a NIP-17 rumor, which has no fetchable id — putting one in a REQ would tell relays the
* private event's identity, the leak [com.vitorpamplona.amethyst.commons.model.Note.isPrivateRumor]
* guards everywhere else. Nothing is lost by not asking: a rumor only ever reaches the cache by
* unwrapping the envelope that carried it, and the always-on gift-wrap tail re-fetches a week of
* those on every cold start, so this flow fires on its own once the envelope lands.
*/
@Composable
fun observeNoteLocally(note: Note): State<NoteState> {
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
inline fun <reified T : Event> observeNoteEvent(
note: Note,
accountViewModel: AccountViewModel,
): State<T?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<T?> = sharedObserveNoteEvent<T>(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { it.note.event as? T? }
}
return flow.collectAsStateWithLifecycle(note.event as? T?)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun <T> observeNoteAndMap(
note: Note,
accountViewModel: AccountViewModel,
map: (Note) -> T,
): State<T> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<T> = sharedObserveNoteAndMap<T>(note, accountViewModel.account, accountViewModel.dataSources().eventFinder, map)
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { map(it.note) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UNCHECKED_CAST")
@Composable
fun <T, U> observeNoteEventAndMapNotNull(
note: Note,
accountViewModel: AccountViewModel,
map: (T) -> U,
): State<U?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<U?> = sharedObserveNoteEventAndMapNotNull<T, U>(note, accountViewModel.account, accountViewModel.dataSources().eventFinder, map)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { (it.note.event as? T)?.let { map(it) } }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) })
}
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UNCHECKED_CAST")
@Composable
fun <T, U> observeNoteEventAndMap(
note: Note,
accountViewModel: AccountViewModel,
map: (T?) -> U,
): State<U> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<U> = sharedObserveNoteEventAndMap<T, U>(note, accountViewModel.account, accountViewModel.dataSources().eventFinder, map)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { map(it.note.event as? T) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note.event as? T))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeNoteHasEvent(
note: Note,
accountViewModel: AccountViewModel,
): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { it.note.event != null }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.event != null)
}
): State<Boolean> = sharedObserveNoteHasEvent(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun observeNoteReplies(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<NoteState?> = sharedObserveNoteReplies(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().replies.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteReplyCount(
note: Note,
accountViewModel: AccountViewModel,
): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<Int> = sharedObserveNoteReplyCount(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.replies.stateFlow
.sample(200)
.mapLatest { it.note.replies.size }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.size)
}
/**
* Count of a chat message's **minichat** replies — its kind-1111 [CommentEvent]
* children only (inline quote-replies are ordinary kind-9/42 messages and are not
* counted here). Drives the "N replies" chip that opens the minichat.
*
* Mounting this registers the message with [EventFinderFilterAssemblerSubscription], which
* batches the visible messages' ids into shared REQs for their replies (kind-1111 among
* them) — so for public chats (NIP-28/NIP-29) the thread replies load, and the chip appears,
* just by rendering the rows. Concord's kind-1111 replies instead arrive over the channel
* plane, so that REQ finds nothing there and is a harmless no-op.
*/
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteMinichatReplyCount(
note: Note,
accountViewModel: AccountViewModel,
): State<Int> {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
val flow =
remember(note) {
note
.flow()
.replies.stateFlow
.sample(200)
.mapLatest { it.note.replies.count { reply -> isMinichatReply(reply.event) } }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.count { isMinichatReply(it.event) })
}
): State<Int> = sharedObserveNoteMinichatReplyCount(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun observeNoteReactions(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<NoteState?> = sharedObserveNoteReactions(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().reactions.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteReactionCount(
note: Note,
accountViewModel: AccountViewModel,
): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.reactions.stateFlow
.sample(200)
.mapLatest { it.note.countReactions() }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(note.countReactions())
}
): State<Int> = sharedObserveNoteReactionCount(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun observeNoteZaps(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().zaps.stateFlow }
return flow.collectAsStateWithLifecycle()
}
): State<NoteState?> = sharedObserveNoteZaps(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun observeNoteReposts(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<NoteState?> = sharedObserveNoteReposts(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().boosts.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeNoteRepostsBy(
note: Note,
user: User,
accountViewModel: AccountViewModel,
): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<Boolean> = sharedObserveNoteRepostsBy(note, user, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.boosts.stateFlow
.mapLatest { it.note.isBoostedBy(user) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(note.isBoostedBy(user))
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteRepostCount(
note: Note,
accountViewModel: AccountViewModel,
): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.boosts.stateFlow
.sample(200)
.mapLatest { note.boosts.size }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.boosts.size)
}
): State<Int> = sharedObserveNoteRepostCount(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun observeNoteReferences(
note: Note,
accountViewModel: AccountViewModel,
): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device.
// On-chain zaps piggyback `note.flow().zaps.stateFlow` because Note.addOnchainZap
// invalidates the same `flowSet.zaps`. If that ever moves to a dedicated flow,
// add it here too — otherwise on-chain-only notes will stop pinging the chevron.
val flow =
remember(note) {
combine(
note.flow().zaps.stateFlow,
note.flow().boosts.stateFlow,
note.flow().reactions.stateFlow,
) { zapState, _, _ ->
zapState.note.hasZapsBoostsOrReactions()
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.hasZapsBoostsOrReactions())
}
): State<Boolean> = sharedObserveNoteReferences(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun observeNoteOts(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
): State<NoteState?> = sharedObserveNoteOts(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().ots.stateFlow }
return flow.collectAsStateWithLifecycle()
}
// Resolves the actual modification list off the main thread and filters identical results,
// so the caller's LaunchedEffect only fires when the list of edits truly changes.
// `sample(500)` collapses bursts — a heavily-edited note can emit hundreds of times during
// initial relay sync, and we only need the last state per ~half second.
// Returns `null` until the first IO resolution completes — callers should treat that as
// "still loading" and not flip their UI to "no edits".
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteModifications(
note: Note,
accountViewModel: AccountViewModel,
): State<List<Note>?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
return produceState<List<Note>?>(initialValue = null, note) {
note
.flow()
.edits
.stateFlow
.sample(500)
.mapLatest { note.textNoteModifications() }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.collect { value = it }
}
}
): State<List<Note>?> = sharedObserveNoteModifications(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun observeCommunityApprovalNeedStatus(
note: Note,
community: Note,
accountViewModel: AccountViewModel,
): State<Boolean?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note, community) {
combine(
community.flow().metadata.stateFlow,
note.flow().boosts.stateFlow,
) { communityMetadata, boosts ->
(communityMetadata.note.event as? CommunityDefinitionEvent)?.let { communityDefEvent ->
val moderators = communityDefEvent.moderatorKeys().toSet()
if (note.author?.pubkeyHex in moderators) {
false
} else {
val isModerator = accountViewModel.account.userProfile().pubkeyHex in moderators
if (isModerator) {
val wasAlreadyApproved =
note.boosts.any {
val approvalEvent = it.event
(approvalEvent is CommunityPostApprovalEvent || approvalEvent is RepostEvent || approvalEvent is GenericRepostEvent) &&
approvalEvent.pubKey in moderators &&
approvalEvent.isForCommunity(community.idHex)
}
!wasAlreadyApproved
} else {
false
}
}
}
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(false)
}
): State<Boolean?> = sharedObserveCommunityApprovalNeedStatus(note, community, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@@ -26,39 +26,22 @@ import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription as SharedUserFinderSubscription
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
fun UserFinderFilterAssemblerSubscription(
user: User,
accountViewModel: AccountViewModel,
) = UserFinderFilterAssemblerSubscription(
) = SharedUserFinderSubscription(
user,
accountViewModel.account,
accountViewModel.dataSources().userFinder,
)
@Composable
fun UserFinderFilterAssemblerSubscription(
user: User,
forAccount: Account,
dataSource: UserFinderFilterAssembler,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(user) {
UserFinderQueryState(user, forAccount)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
/**
* Watches all the users that are cited in the parent post.
* So that we load their relay lists and find posts
@@ -47,10 +47,13 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserAboutMe as sharedObserveUserAboutMe
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserBanner as sharedObserveUserBanner
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo as sharedObserveUserInfo
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture as sharedObserveUserPicture
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
@@ -86,113 +89,30 @@ fun observeUserNickname(
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) })
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserAboutMe(
user: User,
accountViewModel: AccountViewModel,
): State<String> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
): State<String> = sharedObserveUserAboutMe(user, accountViewModel.dataSources().userFinder, accountViewModel.account)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user.metadata().flow.map {
it?.info?.about ?: ""
}
}
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.about ?: "",
)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserInfo(
user: User,
accountViewModel: AccountViewModel,
): State<UserInfo?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
): State<UserInfo?> = sharedObserveUserInfo(user, accountViewModel.dataSources().userFinder, accountViewModel.account)
// Subscribe in the LocalCache for changes that arrive in the device
return user.metadata().flow.collectAsStateWithLifecycle()
}
@SuppressLint("StateFlowValueCalledInComposition")
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserBanner(
user: User,
accountViewModel: AccountViewModel,
): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
): State<String?> = sharedObserveUserBanner(user, accountViewModel.dataSources().userFinder, accountViewModel.account)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.metadata()
.flow
.map {
it?.info?.banner
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.banner,
)
}
@SuppressLint("StateFlowValueCalledInComposition")
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserPicture(
user: User,
accountViewModel: AccountViewModel,
subscribe: Boolean = true,
): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
// Callers that already hold a single shared subscription for the same user
// (e.g. an author avatar that also observes the contact-card score) can pass
// subscribe = false to avoid setting up a redundant relay subscription.
if (subscribe) {
UserFinderFilterAssemblerSubscription(user, accountViewModel)
}
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.metadata()
.flow
.map {
it?.info?.picture
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.picture,
)
}
): State<String?> = sharedObserveUserPicture(user, accountViewModel.dataSources().userFinder, accountViewModel.account, subscribe)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_join
import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_subtitle
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -37,6 +37,7 @@ import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedMediaUrl
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.commons.ui.theme.Size40dp
@@ -154,11 +155,3 @@ fun MyAsyncImage(
}
}
}
fun isAnimatedMediaUrl(url: String): Boolean =
url.endsWith(".gif", ignoreCase = true) ||
url.contains(".gif?", ignoreCase = true) ||
url.contains(".gif#", ignoreCase = true) ||
url.endsWith(".avif", ignoreCase = true) ||
url.contains(".avif?", ignoreCase = true) ||
url.contains(".avif#", ignoreCase = true)
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_invite_onl
import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_private
import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count
import com.vitorpamplona.amethyst.commons.resources.relay_group_open
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedMediaUrl
import com.vitorpamplona.amethyst.commons.service.image.placeholderModel
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
@@ -749,12 +750,7 @@ fun BaseMediaContent.isAnimatedMedia(): Boolean =
if (this is MediaUrlContent) {
mimeType == "image/gif" ||
mimeType == "image/avif" ||
url.endsWith(".gif", ignoreCase = true) ||
url.contains(".gif?", ignoreCase = true) ||
url.contains(".gif#", ignoreCase = true) ||
url.endsWith(".avif", ignoreCase = true) ||
url.contains(".avif?", ignoreCase = true) ||
url.contains(".avif#", ignoreCase = true)
isAnimatedMediaUrl(url)
} else if (this is MediaPreloadedContent) {
mimeType == "image/gif" ||
mimeType == "image/avif" ||
@@ -41,10 +41,10 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_logout_toast
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_logout_toast_zero
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_logout_warning
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -79,7 +80,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -116,6 +116,7 @@ import com.vitorpamplona.amethyst.commons.resources.share_hls_video
import com.vitorpamplona.amethyst.commons.resources.show_npub_as_a_qr_code
import com.vitorpamplona.amethyst.commons.resources.status_update
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -146,7 +147,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.your_profile_image
import com.vitorpamplona.amethyst.commons.search.SearchQuery
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
@@ -45,7 +46,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -77,10 +77,12 @@ import com.vitorpamplona.amethyst.commons.resources.nutzap
import com.vitorpamplona.amethyst.commons.resources.profile_image
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.FollowingIcon
import com.vitorpamplona.amethyst.commons.ui.note.LikedIcon
import com.vitorpamplona.amethyst.commons.ui.note.RepostedIcon
import com.vitorpamplona.amethyst.commons.ui.note.ScoreTagSmall
import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
@@ -105,7 +107,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage
import com.vitorpamplona.amethyst.ui.components.ExpandableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.routes.authorRouteFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.commons.resources.private_rumor_info_title
import com.vitorpamplona.amethyst.commons.resources.private_rumor_mark
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.commons.ui.layouts.NoteComposeLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -117,7 +118,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteModifications
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.expand_relay_list
import com.vitorpamplona.amethyst.commons.resources.relay_info
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier
@@ -70,7 +71,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.relayIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.commons.ui.theme.warningColorOnSecondSurface
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -21,25 +21,18 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -50,18 +43,15 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
import com.vitorpamplona.amethyst.commons.resources.profile_image_of_user
import com.vitorpamplona.amethyst.commons.resources.unknown_author
import com.vitorpamplona.amethyst.commons.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.FollowingIcon
import com.vitorpamplona.amethyst.commons.ui.note.ScoreTag
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font10SP
import com.vitorpamplona.amethyst.commons.ui.theme.Font6SP
import com.vitorpamplona.amethyst.commons.ui.theme.Font8SP
import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
@@ -525,84 +515,6 @@ fun ScoreTag55Preview() {
}
}
@Composable
fun ScoreTag(
score: Int,
size: Dp,
modifier: Modifier,
) {
if (size > 34.dp) {
ScoreTagRegular(score, modifier)
} else if (size > 23.dp) {
ScoreTagSmall(score, modifier)
} else {
ScoreTagSmallest(score, modifier)
}
}
@Composable
fun ScoreTagRegular(
score: Int,
modifier: Modifier,
) {
Text(
text = score.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = Font10SP,
maxLines = 1,
overflow = TextOverflow.Clip,
lineHeight = Font10SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 4.dp, vertical = 0.dp),
)
}
@Composable
fun ScoreTagSmall(
score: Int,
modifier: Modifier,
) {
Text(
text = score.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = Font8SP,
maxLines = 1,
overflow = TextOverflow.Clip,
lineHeight = Font8SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 3.dp, vertical = 0.dp),
)
}
@Composable
fun ScoreTagSmallest(
score: Int,
modifier: Modifier,
) {
Text(
text = score.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = Font6SP,
maxLines = 1,
overflow = TextOverflow.Clip,
lineHeight = Font6SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 2.dp, vertical = 0.dp),
)
}
@Composable
fun WatchProfilePicture(
baseUser: User,
@@ -623,22 +535,17 @@ fun InnerUserPicture(
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val myImageModifier =
remember {
modifier.size(size).clip(shape = CircleShape)
}
RobohashFallbackAsyncImage(
robot = userHex,
model = userPicture,
UserAvatar(
userHex = userHex,
pictureUrl = userPicture,
size = size,
modifier = modifier,
contentDescription =
if (userName != null) {
stringRes(id = Res.string.profile_image_of_user, userName)
} else {
stringRes(id = Res.string.profile_image)
},
modifier = myImageModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
@@ -74,12 +74,12 @@ import com.vitorpamplona.amethyst.commons.resources.badge_awardees_label
import com.vitorpamplona.amethyst.commons.resources.badge_untitled
import com.vitorpamplona.amethyst.commons.resources.reject_badge
import com.vitorpamplona.amethyst.commons.resources.unaccept_badge
import com.vitorpamplona.amethyst.commons.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.preview_card_image_for
import com.vitorpamplona.amethyst.commons.resources.profile_image
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.resources.quick_action_share_browser_link
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
@@ -93,7 +94,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -44,11 +44,11 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.podcast_hosts_and_guests
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -48,11 +48,11 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayNameChip
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.resources.profile_app_recommendations_
import com.vitorpamplona.amethyst.commons.resources.profile_app_recommendations_search
import com.vitorpamplona.amethyst.commons.resources.profile_app_recommendations_search_empty
import com.vitorpamplona.amethyst.commons.resources.select_list_to_filter
import com.vitorpamplona.amethyst.commons.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
@@ -77,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.note.types.ByAuthorChip
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflic
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
@@ -76,7 +77,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
@@ -61,12 +61,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
@@ -242,8 +242,8 @@ private fun arrow(
*/
@Composable
fun rememberPresentation(diff: EventDiff): DiffPresentation {
val context = LocalContext.current
return remember(diff, context) { presentationOf(diff) { context.getString(it) } }
val resources = LocalResources.current
return remember(diff, resources) { presentationOf(diff) { resources.getString(it) } }
}
/** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */
@@ -57,12 +57,12 @@ import com.vitorpamplona.amethyst.commons.resources.badge_untitled
import com.vitorpamplona.amethyst.commons.resources.profile_badges_description
import com.vitorpamplona.amethyst.commons.resources.profile_badges_empty
import com.vitorpamplona.amethyst.commons.resources.profile_badges_title
import com.vitorpamplona.amethyst.commons.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,12 +64,12 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.search.SearchSeed
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.topbars.SearchTopBarAction
@@ -44,9 +44,9 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_add_photo
import com.vitorpamplona.amethyst.commons.resources.marmot_change_photo
import com.vitorpamplona.amethyst.commons.resources.marmot_group_icon
import com.vitorpamplona.amethyst.commons.resources.marmot_remove_photo
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -24,8 +24,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
@@ -67,13 +67,13 @@ import com.vitorpamplona.amethyst.commons.resources.concord_home_empty
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.concord_member_count
import com.vitorpamplona.amethyst.commons.resources.concord_show_all_channels
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.note.timeAgo
@@ -74,12 +74,12 @@ import com.vitorpamplona.amethyst.commons.resources.concord_create_title
import com.vitorpamplona.amethyst.commons.resources.concord_edit_banner_hint
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.remove
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -24,9 +24,9 @@ import com.vitorpamplona.amethyst.commons.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.feeds.ChangesFlowFilter
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
class ChannelFeedFilter(
val channel: Channel,
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.chats.ui.LeaveChannelButton
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HeaderPictureModifier
@@ -47,7 +48,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.resources.channel_image
import com.vitorpamplona.amethyst.commons.resources.created_at
import com.vitorpamplona.amethyst.commons.resources.groups_no_descriptor
import com.vitorpamplona.amethyst.commons.resources.owner
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.chats.ui.JoinChannelButton
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HeaderPictureModifier
@@ -50,7 +51,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -94,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_section_forums
import com.vitorpamplona.amethyst.commons.resources.relay_tor_clearnet_action
import com.vitorpamplona.amethyst.commons.resources.relay_tor_clearnet_body
import com.vitorpamplona.amethyst.commons.resources.relay_tor_clearnet_title
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
@@ -103,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.UserPicture
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count
import com.vitorpamplona.amethyst.commons.resources.relay_group_message_count_short_capped
import com.vitorpamplona.amethyst.commons.resources.relay_group_relay_group_count
import com.vitorpamplona.amethyst.commons.resources.select_list_to_filter
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
@@ -83,7 +84,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -75,12 +75,12 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_parent_pick_titl
import com.vitorpamplona.amethyst.commons.resources.relay_group_parent_search
import com.vitorpamplona.amethyst.commons.resources.relay_group_parent_top_level_option
import com.vitorpamplona.amethyst.commons.resources.relay_group_section_structure
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
@@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -38,11 +38,11 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_server_label
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
import com.vitorpamplona.amethyst.commons.resources.unpin_conversation
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -113,7 +114,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.BlankNote
@@ -70,12 +70,12 @@ import com.vitorpamplona.amethyst.commons.resources.badge_award_image_for
import com.vitorpamplona.amethyst.commons.resources.badge_untitled
import com.vitorpamplona.amethyst.commons.resources.profile_badges_header
import com.vitorpamplona.amethyst.commons.resources.profile_badges_title
import com.vitorpamplona.amethyst.commons.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
import com.vitorpamplona.amethyst.commons.resources.scan_qr
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
@@ -64,7 +65,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.note.ObserveAndDisplayNIP05
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteLocally
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.looking_for_event
import com.vitorpamplona.amethyst.commons.resources.looking_for_event_title
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteLocally
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
@@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_auth_session_undo_bloc
import com.vitorpamplona.amethyst.commons.resources.relay_auth_settings_title
import com.vitorpamplona.amethyst.commons.resources.relay_auth_undo
import com.vitorpamplona.amethyst.commons.resources.relay_info
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -116,7 +117,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.relayAuthPurposeLabelRes
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
@@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.resources.active_subs_title
import com.vitorpamplona.amethyst.commons.resources.active_subs_unattributed
import com.vitorpamplona.amethyst.commons.resources.active_subs_untagged
import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter
@@ -94,7 +95,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -44,6 +44,7 @@ import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons
import com.vitorpamplona.amethyst.commons.ui.components.LocalProfilePictureCache
import com.vitorpamplona.amethyst.commons.ui.theme.AccentBlueDark
import com.vitorpamplona.amethyst.commons.ui.theme.AccentBlueLight
import com.vitorpamplona.amethyst.commons.ui.theme.AccentGreenDark
@@ -189,6 +190,8 @@ fun AmethystTheme(
ProvideAppIcons {
CompositionLocalProvider(
LocalDensity provides scaledDensity,
// ImageLoaderSetup registers the avatar thumbnail cache and the local Blossom bridge.
LocalProfilePictureCache provides true,
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)),
content = content,
)
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.calendar
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -33,13 +34,16 @@ import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
@@ -137,14 +141,20 @@ class CalendarsViewModelFlowTest {
) = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val store = ViewModelStore()
var modelScope: Job? = null
try {
val feed = FeedContentState(CalendarAppointmentsFeedFilter(seeEverythingAccount()), scope, LocalCache)
val model = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java]
modelScope = model.viewModelScope.coroutineContext.job
model.init(pubKey, feed)
block(model)
} finally {
store.clear()
scope.cancel()
// Clearing only requests cancellation. The flowOn producers on Dispatchers.Default can
// still be finishing and hand their completion back to the Main collectors; wait for
// them here, while setMain is still in force, not after tearDown's resetMain.
withTimeout(AWAIT_MS) { modelScope?.join() }
}
}
@@ -22,13 +22,19 @@ package com.vitorpamplona.amethyst.calendar
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewMode
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.startOfWeek
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -58,13 +64,24 @@ class CalendarsViewModelTest {
store = ViewModelStore()
}
private val scopes = mutableListOf<Job>()
@After
fun tearDown() {
store.clear()
// Clearing only requests cancellation: a producer the model runs on Dispatchers.Default
// (its flowOn) can still be finishing, and it hands its completion back to the Main
// collector. Waiting for the scopes keeps that inside setMain, instead of throwing onto a
// background thread after resetMain and failing whichever runTest starts next.
runBlocking { withTimeout(SCOPE_CLOSE_MS) { scopes.joinAll() } }
scopes.clear()
Dispatchers.resetMain()
}
private fun newModel(): CalendarsViewModel = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java]
private fun newModel(): CalendarsViewModel =
ViewModelProvider(store, ViewModelProvider.NewInstanceFactory())[CalendarsViewModel::class.java].also {
scopes += it.viewModelScope.coroutineContext.job
}
@Test
fun aFreshScreenOpensOnToday() {
@@ -148,4 +165,8 @@ class CalendarsViewModelTest {
assertEquals("the lenses must not share one scroll offset", 4, states.distinct().size)
}
companion object {
private const val SCOPE_CLOSE_MS = 5_000L
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats
package com.vitorpamplona.amethyst.commons.model.chats
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
/** True for GIF urls (path ends in `.gif`, optionally followed by a query or fragment). */
fun isAnimatedGifUrl(url: String): Boolean =
url.endsWith(".gif", ignoreCase = true) ||
url.contains(".gif?", ignoreCase = true) ||
url.contains(".gif#", ignoreCase = true)
/** True for AVIF urls (path ends in `.avif`, optionally followed by a query or fragment). */
fun isAvifUrl(url: String): Boolean =
url.endsWith(".avif", ignoreCase = true) ||
url.contains(".avif?", ignoreCase = true) ||
url.contains(".avif#", ignoreCase = true)
/** True for GIF/AVIF urls, which may animate and so bypass still-image pipelines like the avatar thumbnail cache. */
fun isAnimatedMediaUrl(url: String): Boolean = isAnimatedGifUrl(url) || isAvifUrl(url)
@@ -18,12 +18,12 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats
package com.vitorpamplona.amethyst.commons.model.chats
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A kind-9 chat message is a thread reply only when its `e` tag carries a NIP-10 marker.
+4 -4
View File
@@ -46,11 +46,11 @@ is specific to the UI module.
| Source set | For |
|---------------|-----|
| `commonMain` | Composables, icons, theme, Coil fetchers, `composeResources`. Gated by `verifyKmpPurity` like `commons`. |
| `jvmAndroid` | Markdown renderer (`ui/markdown`), Coil-OkHttp + Blossom read-auth fetcher, the `viewModel()` helper. |
| `jvmMain` | Desktop Coil bridge (`CoilImageBridge.jvm.kt`), `compose.desktop.currentOs`. `dependsOn(jvmAndroid)` + `skikoMain`. |
| `androidMain` | Android Coil bridge. `dependsOn(jvmAndroid)`. |
| `jvmAndroid` | Markdown renderer (`ui/markdown`), Coil-OkHttp + Blossom read-auth fetcher, the `viewModel()` helper, and the avatar/image engine (`ui/components/RobohashAsyncImage.kt`: `RobohashFallbackAsyncImage`, `GifProfilePicture`) behind the `UserAvatar` `AvatarImage` actual. For a user avatar call the commonMain `UserAvatar`; don't add another avatar composable. |
| `jvmMain` | Desktop Coil bridge (`CoilImageBridge.jvm.kt`), a no-op `AnimatedImageAutoPlay` (Coil decodes only the first frame), `compose.desktop.currentOs`. `dependsOn(jvmAndroid)` + `skikoMain`. |
| `androidMain` | Android Coil bridge, the `AnimatedImageAutoPlay` actual that starts/stops GIF/AVIF drawables. `dependsOn(jvmAndroid)`. |
| `skikoMain` | `org.jetbrains.skia` pixel helpers shared by desktop JVM + iOS (`SkiaBitmapConverter`). |
| `iosMain` | iOS Coil bridge. Compile-only spike today. |
| `iosMain` | iOS Coil bridge, a plain `AsyncImage` `AvatarImage` actual. Compile-only spike today. |
## Tooling that points here
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.components
import android.graphics.drawable.Animatable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import coil3.Image
import coil3.asDrawable
@Composable
internal actual fun AnimatedImageAutoPlay(
image: Image?,
autoPlay: Boolean,
) {
val resources = LocalContext.current.resources
// asDrawable wraps a bitmap in a new BitmapDrawable on every call; remembering it keeps the
// effect below from restarting on each recomposition.
val drawable = remember(image, resources) { image?.asDrawable(resources) }
LaunchedEffect(drawable, autoPlay) {
if (drawable is Animatable) {
if (autoPlay) drawable.start() else drawable.stop()
}
}
}
@@ -0,0 +1,516 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply
import com.vitorpamplona.amethyst.commons.model.textNoteModifications
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.sample
/*
* Per-note observers: each subscribes [note] with the relay event finder (batched with every other
* on-screen note) and exposes a LocalCache flow of the note as Compose state.
*
* [account] and [dataSource] default to the front end's [LocalUserFinderAccount] and
* [LocalEventFinder], so a shared composable needs only the note. Callers outside a composition
* that provides them (a separate Activity root) pass both explicitly.
*/
@Composable
fun observeNote(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<NoteState> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
/**
* [observeNote] without the relay half: watches LocalCache and asks no relay for the note.
*
* For a NIP-17 rumor, which has no fetchable id — putting one in a REQ would tell relays the
* private event's identity, the leak [com.vitorpamplona.amethyst.commons.model.Note.isPrivateRumor]
* guards everywhere else. Nothing is lost by not asking: a rumor only ever reaches the cache by
* unwrapping the envelope that carried it, and the always-on gift-wrap tail re-fetches a week of
* those on every cold start, so this flow fires on its own once the envelope lands.
*/
@Composable
fun observeNoteLocally(note: Note): State<NoteState> {
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
inline fun <reified T : Event> observeNoteEvent(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<T?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { it.note.event as? T? }
}
return flow.collectAsStateWithLifecycle(note.event as? T?)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun <T> observeNoteAndMap(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
map: (Note) -> T,
): State<T> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { map(it.note) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UNCHECKED_CAST")
@Composable
fun <T, U> observeNoteEventAndMapNotNull(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
map: (T) -> U,
): State<U?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { (it.note.event as? T)?.let { map(it) } }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) })
}
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UNCHECKED_CAST")
@Composable
fun <T, U> observeNoteEventAndMap(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
map: (T?) -> U,
): State<U> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { map(it.note.event as? T) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note.event as? T))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeNoteHasEvent(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { it.note.event != null }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.event != null)
}
@Composable
fun observeNoteReplies(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().replies.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteReplyCount(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.replies.stateFlow
.sample(200)
.mapLatest { it.note.replies.size }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.size)
}
/**
* Count of a chat message's **minichat** replies — its kind-1111 [CommentEvent]
* children only (inline quote-replies are ordinary kind-9/42 messages and are not
* counted here). Drives the "N replies" chip that opens the minichat.
*
* Mounting this registers the message with [EventFinderFilterAssemblerSubscription], which
* batches the visible messages' ids into shared REQs for their replies (kind-1111 among
* them) — so for public chats (NIP-28/NIP-29) the thread replies load, and the chip appears,
* just by rendering the rows. Concord's kind-1111 replies instead arrive over the channel
* plane, so that REQ finds nothing there and is a harmless no-op.
*/
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteMinichatReplyCount(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Int> {
EventFinderFilterAssemblerSubscription(note, account, dataSource)
val flow =
remember(note) {
note
.flow()
.replies.stateFlow
.sample(200)
.mapLatest { it.note.replies.count { reply -> isMinichatReply(reply.event) } }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.count { isMinichatReply(it.event) })
}
@Composable
fun observeNoteReactions(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().reactions.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteReactionCount(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.reactions.stateFlow
.sample(200)
.mapLatest { it.note.countReactions() }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(note.countReactions())
}
@Composable
fun observeNoteZaps(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().zaps.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@Composable
fun observeNoteReposts(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().boosts.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeNoteRepostsBy(
note: Note,
user: User,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.boosts.stateFlow
.mapLatest { it.note.isBoostedBy(user) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(note.isBoostedBy(user))
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteRepostCount(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Int> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note) {
note
.flow()
.boosts.stateFlow
.sample(200)
.mapLatest { note.boosts.size }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.boosts.size)
}
@Composable
fun observeNoteReferences(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Boolean> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device.
// On-chain zaps piggyback `note.flow().zaps.stateFlow` because Note.addOnchainZap
// invalidates the same `flowSet.zaps`. If that ever moves to a dedicated flow,
// add it here too — otherwise on-chain-only notes will stop pinging the chevron.
val flow =
remember(note) {
combine(
note.flow().zaps.stateFlow,
note.flow().boosts.stateFlow,
note.flow().reactions.stateFlow,
) { zapState, _, _ ->
zapState.note.hasZapsBoostsOrReactions()
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.hasZapsBoostsOrReactions())
}
@Composable
fun observeNoteOts(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().ots.stateFlow }
return flow.collectAsStateWithLifecycle()
}
// Resolves the actual modification list off the main thread and filters identical results,
// so the caller's LaunchedEffect only fires when the list of edits truly changes.
// `sample(500)` collapses bursts — a heavily-edited note can emit hundreds of times during
// initial relay sync, and we only need the last state per ~half second.
// Returns `null` until the first IO resolution completes — callers should treat that as
// "still loading" and not flip their UI to "no edits".
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteModifications(
note: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<List<Note>?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
return produceState<List<Note>?>(initialValue = null, note) {
note
.flow()
.edits
.stateFlow
.sample(500)
.mapLatest { note.textNoteModifications() }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.collect { value = it }
}
}
@Composable
fun observeCommunityApprovalNeedStatus(
note: Note,
community: Note,
account: UserFinderAccount = LocalUserFinderAccount.current,
dataSource: EventFinderFilterAssembler = LocalEventFinder.current,
): State<Boolean?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, account, dataSource)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note, community) {
combine(
community.flow().metadata.stateFlow,
note.flow().boosts.stateFlow,
) { communityMetadata, boosts ->
(communityMetadata.note.event as? CommunityDefinitionEvent)?.let { communityDefEvent ->
val moderators = communityDefEvent.moderatorKeys().toSet()
if (note.author?.pubkeyHex in moderators) {
false
} else {
val isModerator = account.userFinderPubkeyHex in moderators
if (isModerator) {
val wasAlreadyApproved =
note.boosts.any {
val approvalEvent = it.event
(approvalEvent is CommunityPostApprovalEvent || approvalEvent is RepostEvent || approvalEvent is GenericRepostEvent) &&
approvalEvent.pubKey in moderators &&
approvalEvent.isForCommunity(community.idHex)
}
!wasAlreadyApproved
} else {
false
}
}
}
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(false)
}
@@ -62,8 +62,13 @@ fun observeUserPicture(
user: User,
userFinder: UserFinderFilterAssembler,
account: UserFinderAccount,
// Callers that already hold a subscription for the same user (e.g. an author avatar that
// also observes the contact-card score) pass false to skip a redundant relay subscription.
subscribe: Boolean = true,
): State<String?> {
UserFinderFilterAssemblerSubscription(user, account, userFinder)
if (subscribe) {
UserFinderFilterAssemblerSubscription(user, account, userFinder)
}
val flow =
remember(user) {
@@ -20,28 +20,16 @@
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
/**
* Wrapper class for profile picture URLs that signals Coil to use the thumbnail
@@ -54,7 +42,16 @@ data class ProfilePictureUrl(
)
/**
* Shared avatar component that displays a user's profile picture with Robohash fallback.
* True when the host's Coil pipeline registers a `Fetcher.Factory<ProfilePictureUrl>` (the
* avatar thumbnail cache) and the local Blossom cache bridge that strips the profile-picture
* request marker. Avatars then load http(s) pictures through that cache. Hosts without them
* (desktop, iOS) keep the default `false`, so avatars load the plain URL.
*/
val LocalProfilePictureCache = staticCompositionLocalOf { false }
/**
* The shared user avatar: the profile picture clipped to a circle, with a Robohash (or a generic
* face icon) while it loads, when it fails, or when pictures are turned off.
*
* @param userHex The user's public key hex (used for Robohash generation)
* @param pictureUrl Optional URL to the user's profile picture
@@ -63,7 +60,8 @@ data class ProfilePictureUrl(
* @param contentDescription Accessibility description
* @param loadProfilePicture Whether to load the profile picture (false = show robohash only)
* @param loadRobohash Whether to generate robohash (false = show generic icon)
* @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads
* @param autoPlayGif Whether animated (GIF/AVIF) pictures play. Only Android can animate them;
* desktop shows their first frame either way.
* @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. When null the avatar renders as before (no extra
@@ -78,7 +76,7 @@ fun UserAvatar(
contentDescription: String? = null,
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
autoPlayGif: Boolean = true,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
if (badge != null) {
@@ -91,7 +89,7 @@ fun UserAvatar(
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
autoPlayGif = autoPlayGif,
badge = null,
)
badge()
@@ -106,53 +104,25 @@ fun UserAvatar(
.clip(shape = CircleShape)
}
val imageModel: Any? =
if (pictureUrl != null && useThumbnailCache) {
ProfilePictureUrl(pictureUrl)
} else {
pictureUrl
}
if (imageModel != null && loadProfilePicture) {
// Show profile picture with robohash/icon as fallback
val fallbackPainter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight),
)
} else {
rememberMaterialSymbolPainter(MaterialSymbols.Face)
}
AsyncImage(
model = imageModel,
contentDescription = contentDescription,
modifier = avatarModifier,
placeholder = fallbackPainter,
fallback = fallbackPainter,
error = fallbackPainter,
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
alpha = DefaultAlpha,
colorFilter = null,
filterQuality = DrawScope.DefaultFilterQuality,
)
} else if (loadRobohash) {
// Show robohash only
Image(
imageVector = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight),
contentDescription = contentDescription,
modifier = avatarModifier,
contentScale = ContentScale.Crop,
)
} else {
// Show generic icon
Image(
painter = rememberMaterialSymbolPainter(MaterialSymbols.Face),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground),
modifier = avatarModifier,
contentScale = ContentScale.Crop,
)
}
AvatarImage(
userHex = userHex,
pictureUrl = pictureUrl,
contentDescription = contentDescription,
modifier = avatarModifier,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
autoPlayGif = autoPlayGif,
)
}
/** Draws the avatar picture into an already sized and clipped [modifier]. */
@Composable
internal expect fun AvatarImage(
userHex: String,
pictureUrl: String?,
contentDescription: String?,
modifier: Modifier,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
autoPlayGif: Boolean,
)
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.theme.Font10SP
import com.vitorpamplona.amethyst.commons.ui.theme.Font6SP
import com.vitorpamplona.amethyst.commons.ui.theme.Font8SP
import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
/**
* Trust-score chip drawn over the bottom of an avatar, sized to the avatar: regular above 34dp,
* small above 23dp, smallest below.
*/
@Composable
fun ScoreTag(
score: Int,
size: Dp,
modifier: Modifier,
) {
if (size > 34.dp) {
ScoreTagRegular(score, modifier)
} else if (size > 23.dp) {
ScoreTagSmall(score, modifier)
} else {
ScoreTagSmallest(score, modifier)
}
}
@Composable
fun ScoreTagRegular(
score: Int,
modifier: Modifier,
) {
Text(
text = score.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = Font10SP,
maxLines = 1,
overflow = TextOverflow.Clip,
lineHeight = Font10SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 4.dp, vertical = 0.dp),
)
}
@Composable
fun ScoreTagSmall(
score: Int,
modifier: Modifier,
) {
Text(
text = score.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = Font8SP,
maxLines = 1,
overflow = TextOverflow.Clip,
lineHeight = Font8SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 3.dp, vertical = 0.dp),
)
}
@Composable
fun ScoreTagSmallest(
score: Int,
modifier: Modifier,
) {
Text(
text = score.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = Font6SP,
maxLines = 1,
overflow = TextOverflow.Clip,
lineHeight = Font6SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 2.dp, vertical = 0.dp),
)
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.onBackgroundColorFilter
// iOS has no animated-avatar pipeline yet, so autoPlayGif is unused.
@Composable
internal actual fun AvatarImage(
userHex: String,
pictureUrl: String?,
contentDescription: String?,
modifier: Modifier,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
autoPlayGif: Boolean,
) {
if (pictureUrl != null && loadProfilePicture) {
val fallbackPainter =
if (loadRobohash) {
rememberVectorPainter(image = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight))
} else {
rememberMaterialSymbolPainter(MaterialSymbols.Face)
}
AsyncImage(
model = pictureUrl,
contentDescription = contentDescription,
modifier = modifier,
placeholder = fallbackPainter,
fallback = fallbackPainter,
error = fallbackPainter,
contentScale = ContentScale.Crop,
)
} else if (loadRobohash) {
Image(
imageVector = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Crop,
)
} else {
Image(
painter = rememberMaterialSymbolPainter(MaterialSymbols.Face),
contentDescription = contentDescription,
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
modifier = modifier,
contentScale = ContentScale.Crop,
)
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@Composable
internal actual fun AvatarImage(
userHex: String,
pictureUrl: String?,
contentDescription: String?,
modifier: Modifier,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
autoPlayGif: Boolean,
) {
RobohashFallbackAsyncImage(
robot = userHex,
model = pictureUrl,
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Crop,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
autoPlayGif = autoPlayGif,
)
}
@@ -18,15 +18,13 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
package com.vitorpamplona.amethyst.commons.ui.components
import android.graphics.drawable.Animatable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -38,9 +36,8 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.asDrawable
import coil3.compose.AsyncImagePainter
import coil3.compose.LocalPlatformContext
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.network.NetworkHeaders
@@ -48,12 +45,23 @@ import coil3.network.httpHeaders
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedMediaUrl
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
import com.vitorpamplona.amethyst.commons.ui.components.forwardingPainter
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.onBackgroundColorFilter
import coil3.Image as CoilImage
/**
* Plays or pauses an animated avatar (GIF/AVIF) once Coil has decoded it. Android drives the
* decoded `Animatable` drawable; desktop decodes only the first frame, so there [autoPlay] has
* no effect.
*/
@Composable
internal expect fun AnimatedImageAutoPlay(
image: CoilImage?,
autoPlay: Boolean,
)
@Composable
fun RobohashAsyncImage(
@@ -93,6 +101,7 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean,
autoPlayGif: Boolean = true,
) {
val useThumbnailCache = LocalProfilePictureCache.current
if (model != null && loadProfilePicture && isAnimatedMediaUrl(model)) {
GifProfilePicture(
userHex = robot,
@@ -115,14 +124,14 @@ fun RobohashFallbackAsyncImage(
)
}
val resources = LocalContext.current.resources
SubcomposeAsyncImage(
// The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only
// ProfilePictureUrl only loads where the host registered its thumbnail-cache fetcher
// (see LocalProfilePictureCache). That fetcher delegates to Coil's http-only
// NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at
// file://) would fail there. Route only remote http(s) pictures through the thumbnail
// cache; hand local/content URIs to Coil's native fetchers, which load them directly.
model =
if (model.startsWith("http://", ignoreCase = true) || model.startsWith("https://", ignoreCase = true)) {
if (useThumbnailCache && (model.startsWith("http://", ignoreCase = true) || model.startsWith("https://", ignoreCase = true))) {
ProfilePictureUrl(model)
} else {
model
@@ -136,14 +145,7 @@ fun RobohashFallbackAsyncImage(
filterQuality = filterQuality,
) {
val state by painter.state.collectAsState()
val successState = state as? AsyncImagePainter.State.Success
val drawable = successState?.result?.image?.asDrawable(resources)
LaunchedEffect(drawable, autoPlayGif) {
if (drawable is Animatable) {
if (autoPlayGif) drawable.start() else drawable.stop()
}
}
AnimatedImageAutoPlay((state as? AsyncImagePainter.State.Success)?.result?.image, autoPlayGif)
when (state) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
@@ -194,7 +196,6 @@ fun GifProfilePicture(
loadRobohash: Boolean,
autoPlay: Boolean,
) {
val resources = LocalContext.current.resources
val fallbackPainter =
if (loadRobohash) {
rememberVectorPainter(image = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight))
@@ -205,20 +206,27 @@ fun GifProfilePicture(
)
}
val context = LocalContext.current
val context = LocalPlatformContext.current
val markForLocalBlossom = LocalProfilePictureCache.current
// Animated avatars skip ProfilePictureFetcher (its thumbnail cache would flatten them), so
// they carry the profile-picture marker themselves for the local Blossom cache bridge.
val model =
remember(userPicture) {
ImageRequest
.Builder(context)
.data(userPicture)
.httpHeaders(
NetworkHeaders
.Builder()
.set(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE)
.build(),
).build()
// they carry the profile-picture marker themselves for the local Blossom cache bridge. Only
// hosts that install that bridge (LocalProfilePictureCache) strip the marker before the
// request leaves; anywhere else it would reach the image server, so it is not added.
val model: Any =
remember(userPicture, markForLocalBlossom) {
if (markForLocalBlossom) {
ImageRequest
.Builder(context)
.data(userPicture)
.httpHeaders(
NetworkHeaders
.Builder()
.set(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE)
.build(),
).build()
} else {
userPicture
}
}
Box(modifier = modifier) {
@@ -229,14 +237,7 @@ fun GifProfilePicture(
modifier = Modifier.fillMaxSize(),
) {
val state by painter.state.collectAsState()
val successState = state as? AsyncImagePainter.State.Success
val drawable = successState?.result?.image?.asDrawable(resources)
LaunchedEffect(drawable, autoPlay) {
if (drawable is Animatable) {
if (autoPlay) drawable.start() else drawable.stop()
}
}
AnimatedImageAutoPlay((state as? AsyncImagePainter.State.Success)?.result?.image, autoPlay)
when (state) {
is AsyncImagePainter.State.Success -> {
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
import coil3.Image
@Composable
internal actual fun AnimatedImageAutoPlay(
image: Image?,
autoPlay: Boolean,
) {
// Coil on desktop decodes only the first frame of a GIF/AVIF, so there is no animation to
// start or stop and autoPlay has no effect here.
}
@@ -49,10 +49,10 @@ import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import kotlinx.coroutines.delay
/**
@@ -51,13 +51,6 @@ private const val MIN_FRAME_DURATION_MS = 20
private val gifHttpClient get() = DesktopHttpClient.currentClient()
fun isAnimatedGifUrl(url: String): Boolean {
val lower = url.lowercase()
return lower.endsWith(".gif") ||
lower.contains(".gif?") ||
lower.contains(".gif#")
}
private class GifFrames(
val frames: List<ImageBitmap>,
val durations: List<Int>,
@@ -40,6 +40,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
/**
@@ -37,6 +37,7 @@ import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssem
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
@@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -54,7 +54,6 @@ fun WoTBadgedAvatar(
contentDescription: String? = null,
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
) {
val service = LocalWoTService.current
val ready = LocalWoTReady.current
@@ -75,7 +74,6 @@ fun WoTBadgedAvatar(
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
badge =
if (score > 0) {
{