From 0a5aed7ec5f819f9cbe1c07345f1dd85ca7a6954 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:59:42 +0000 Subject: [PATCH 1/5] refactor: share the user avatar image, score tags and metadata observers The avatar is the second most common dependency of the app's composables (after AccountViewModel). This moves its rendering and the observers under it into commonsUI, leaving the app with the settings/account reads. - Observers: the app's observeUserInfo, observeUserAboutMe, observeUserBanner and observeUserPicture were copies of the commonsUI relayClient/user ones, and its UserFinderFilterAssemblerSubscription (user, Account, dataSource) a copy of the shared subscription. They now delegate, keeping their signatures so no call site changes. The shared observeUserPicture gains the app's `subscribe` flag. observeUserName stays app-side: it applies the account's contact-card petname. (The shared subscription remembers per user *and* account, so an account switch no longer reuses the previous account's query state.) - ScoreTag / ScoreTagRegular / Small / Smallest move to ui/note. - RobohashAsyncImage, RobohashFallbackAsyncImage and GifProfilePicture move to commonsUI jvmAndroid ui/components (they tag requests with the jvmAndroid-only LocalBlossomCacheRedirectInterceptor header). The one Android API, starting/stopping an animated avatar's Animatable drawable, becomes AnimatedImageAutoPlay: an expect in jvmAndroid with the Android actual and a desktop no-op. The Coil request uses LocalPlatformContext instead of LocalContext. - isAnimatedMediaUrl, a pure URL check, moves to commons richtext. - UserPictureImage(userHex, picture, name, size, modifier, loadProfilePicture, loadRobohash, autoPlayGif) in commonsUI jvmAndroid ui/note is the display half of InnerUserPicture, which keeps reading the three settings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../consent/ConnectedAccountRow.kt | 2 +- .../consent/SignerConsentActivity.kt | 2 +- .../napplet/NappletConsentActivity.kt | 2 +- .../compose/RelayAuthPromptHost.kt | 2 +- .../UserFinderFilterAssemblerSubscription.kt | 12 +- .../reqCommand/user/UserObservers.kt | 96 ++------------ .../ui/components/ConcordInviteCard.kt | 1 + .../amethyst/ui/components/MyAsyncImage.kt | 9 +- .../amethyst/ui/components/RelayGroupCard.kt | 1 + .../bottombars/GroupBottomBarEntries.kt | 2 +- .../drawer/AccountSwitchBottomSheet.kt | 2 +- .../ui/navigation/drawer/DrawerContent.kt | 2 +- .../topbars/UserDrawerSearchTopBar.kt | 2 +- .../amethyst/ui/note/MultiSetCompose.kt | 3 +- .../amethyst/ui/note/NoteCompose.kt | 2 +- .../amethyst/ui/note/RelayListRow.kt | 2 +- .../amethyst/ui/note/UserProfilePicture.kt | 118 ++--------------- .../amethyst/ui/note/types/Badge.kt | 2 +- .../amethyst/ui/note/types/CommunityHeader.kt | 2 +- .../amethyst/ui/note/types/PodcastPeople.kt | 2 +- .../ui/note/types/RelayGroupMessage.kt | 2 +- .../ProfileAppRecommendationsScreen.kt | 2 +- .../backups/BackupConflictEventViews.kt | 2 +- .../backups/BackupConflictListViews.kt | 2 +- .../badges/profile/ProfileBadgesScreen.kt | 2 +- .../chats/geohashChat/GeohashChatScreen.kt | 2 +- .../marmotGroup/MarmotGroupIconEditor.kt | 2 +- .../concord/ConcordHomeScreen.kt | 2 +- .../concord/ConcordMetadataForm.kt | 2 +- .../header/ShortEphemeralChatChannelHeader.kt | 2 +- .../header/LongPublicChatChannelHeader.kt | 2 +- .../header/ShortPublicChatChannelHeader.kt | 2 +- .../relayGroup/RelayGroupChannelListScreen.kt | 2 +- .../relayGroup/RelayGroupDiscoveryScreen.kt | 2 +- .../relayGroup/RelayGroupParentPicker.kt | 2 +- .../send/ChannelFileUploadDialog.kt | 2 +- .../chats/rooms/ChatroomHeaderCompose.kt | 2 +- .../profile/header/badges/DisplayBadges.kt | 4 +- .../ui/screen/loggedIn/qrcode/ShowQRScreen.kt | 2 +- .../relayauth/RelayAuthSettingsScreen.kt | 2 +- .../ActiveSubscriptionsScreen.kt | 2 +- .../commons/richtext/AnimatedMediaUrl.kt | 30 +++++ .../AnimatedImageAutoPlay.android.kt | 43 +++++++ .../relayClient/user/UserMetadataObservers.kt | 7 +- .../amethyst/commons/ui/note/ScoreTag.kt | 119 ++++++++++++++++++ .../ui/components/RobohashAsyncImage.kt | 43 +++---- .../commons/ui/note/UserPictureImage.kt | 73 +++++++++++ .../components/AnimatedImageAutoPlay.jvm.kt | 32 +++++ 48 files changed, 379 insertions(+), 278 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt create mode 100644 commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ScoreTag.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/ui/components/RobohashAsyncImage.kt (88%) create mode 100644 commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserPictureImage.kt create mode 100644 commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt index c4e20fe1cb..58950306c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index 2f627041d9..7d610c2ffd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt index 96ddf7fb70..49e9bb54c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -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 /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt index 81d8ae1207..4a09f6706a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt index 9d67b5d442..2774e6f8c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -31,6 +31,7 @@ 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 @@ -48,16 +49,7 @@ 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) -} +) = SharedUserFinderSubscription(user, forAccount, dataSource) /** * Watches all the users that are cited in the parent post. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index b314ab7cd0..691179b7e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -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 { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) +): State = 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 { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) +): State = 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 { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) +): State = 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 { - // 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 = sharedObserveUserPicture(user, accountViewModel.dataSources().userFinder, accountViewModel.account, subscribe) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index 8276f2016b..ab6f170785 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt index 757b7e4244..6446525a49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt index ef9bf4b422..7d7ac96c3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt index 9add6f2fa2..428b2ac946 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 02f8689296..c828e83cee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index efd48a5e86..77cf8b53a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index cc1d87909b..4998b3ae15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 775488a32a..f3d5b37d0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 20c88f6e1c..e83291659c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index eb589ce309..c2c54ffb9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 5cfa7ff7cd..3aa93de82f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -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 @@ -47,21 +40,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User 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.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.note.UserPictureImage 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 +513,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 +533,12 @@ fun InnerUserPicture( modifier: Modifier, accountViewModel: AccountViewModel, ) { - val myImageModifier = - remember { - modifier.size(size).clip(shape = CircleShape) - } - - RobohashFallbackAsyncImage( - robot = userHex, - model = userPicture, - 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, + UserPictureImage( + userHex = userHex, + userPicture = userPicture, + userName = userName, + size = size, + modifier = modifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 4522c97552..2ea85eed91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 8a5eb85fe9..8a95513673 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt index 5b25f5ecc1..9bd69eb2ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayGroupMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayGroupMessage.kt index 67ff0aff42..aa62be4e9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayGroupMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayGroupMessage.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index a56295a56c..9d462ebcf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt index d68ba9f721..5667f0e30e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt index e1beb6c625..c8feeaa482 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt index 5b09074883..0d3cbdf7fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/ProfileBadgesScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt index ce9df281b9..fb7b9de13f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt index f0fabf7766..faa029d834 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 8df0607a89..a4300dae56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index eec6288437..6dafebd7ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 9fc763710b..da2bce20a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index ae56c76d54..a5757f0ed1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index 317783b3a6..531c6c4399 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 43ccd79d8d..8093f081eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index e6e0a4a720..e55e17f57c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt index c0823de922..627c5face7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 96e70b3588..ec0e05f483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 9772cc57ff..43bc9c72c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 40a1adaf7a..aea887d665 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt index c733ea070f..4321485aa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index b867cb022e..83deb6a5f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index f513a35aed..42fcf0d8e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt new file mode 100644 index 0000000000..8f3a251674 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt @@ -0,0 +1,30 @@ +/* + * 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/AVIF urls, which may animate and so bypass still-image pipelines like the avatar thumbnail cache. */ +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) diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt new file mode 100644 index 0000000000..f93981c56b --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt @@ -0,0 +1,43 @@ +/* + * 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.ui.platform.LocalContext +import coil3.Image +import coil3.asDrawable + +@Composable +internal actual fun AnimatedImageAutoPlay( + image: Image?, + autoPlay: Boolean, +) { + val resources = LocalContext.current.resources + val drawable = image?.asDrawable(resources) + + LaunchedEffect(drawable, autoPlay) { + if (drawable is Animatable) { + if (autoPlay) drawable.start() else drawable.stop() + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt index c1d20a41d9..f2dd189808 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt @@ -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 { - UserFinderFilterAssemblerSubscription(user, account, userFinder) + if (subscribe) { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + } val flow = remember(user) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ScoreTag.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ScoreTag.kt new file mode 100644 index 0000000000..bb69d45d37 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ScoreTag.kt @@ -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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashAsyncImage.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt rename to commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashAsyncImage.kt index dcfce38a90..c1725fb28b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashAsyncImage.kt @@ -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,22 @@ 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 has no such drawable, so it is a no-op there. + */ +@Composable +internal expect fun AnimatedImageAutoPlay( + image: CoilImage?, + autoPlay: Boolean, +) @Composable fun RobohashAsyncImage( @@ -115,7 +122,6 @@ fun RobohashFallbackAsyncImage( ) } - val resources = LocalContext.current.resources SubcomposeAsyncImage( // The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only // NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at @@ -136,14 +142,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 +193,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,7 +203,7 @@ fun GifProfilePicture( ) } - val context = LocalContext.current + val context = LocalPlatformContext.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 = @@ -229,14 +227,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 -> { diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserPictureImage.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserPictureImage.kt new file mode 100644 index 0000000000..b3234d1f08 --- /dev/null +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserPictureImage.kt @@ -0,0 +1,73 @@ +/* + * 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.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +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.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * A user's round avatar: their picture when [loadProfilePicture] allows it, otherwise (or while + * loading / on error) their robohash, or a plain face icon when [loadRobohash] is off. + * Animated pictures play only when [autoPlayGif]. + */ +@Composable +fun UserPictureImage( + userHex: String, + userPicture: String?, + userName: String?, + size: Dp, + modifier: Modifier, + loadProfilePicture: Boolean, + loadRobohash: Boolean, + autoPlayGif: Boolean, +) { + val myImageModifier = + remember { + modifier.size(size).clip(shape = CircleShape) + } + + RobohashFallbackAsyncImage( + robot = userHex, + model = userPicture, + 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 = loadProfilePicture, + loadRobohash = loadRobohash, + autoPlayGif = autoPlayGif, + ) +} diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt new file mode 100644 index 0000000000..3edd5be51c --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt @@ -0,0 +1,32 @@ +/* + * 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, +) { + // Desktop decodes animated avatars without a controllable drawable; nothing to toggle. +} From ef845067a48d26da69584b98a3323a53ccbd3b21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:21:14 +0000 Subject: [PATCH 2/5] fix: address the avatar audit and fold UserPictureImage into UserAvatar - UserAvatar is now the one shared avatar. It keeps the remember(size, modifier) keys and hands drawing to an AvatarImage expect: jvmAndroid uses RobohashFallbackAsyncImage (GIF/AVIF playback included), iOS a plain AsyncImage. UserPictureImage is gone; InnerUserPicture calls UserAvatar. - The ProfilePictureUrl thumbnail-cache wrapping and the local-Blossom request marker are now gated on LocalProfilePictureCache, which the Android AmethystTheme provides. Desktop has neither the fetcher nor the interceptor, so it loads plain URLs and never leaks the marker header. The unused per-call useThumbnailCache flag is removed. - AnimatedImageAutoPlay remembers the drawable, so a bitmap is no longer rewrapped and the effect no longer restarts on every recomposition. The desktop no-op and the expect document that autoPlay has no effect there. - One animated-URL check: isAnimatedGifUrl / isAvifUrl / isAnimatedMediaUrl in commons richtext, used by ZoomableContentView and the desktop GIF paths. - Drop the app's forwarding 3-arg UserFinderFilterAssemblerSubscription. - commonsUI/ARCHITECTURE.md lists the avatar engine and its actuals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../UserFinderFilterAssemblerSubscription.kt | 11 +- .../ui/components/ZoomableContentView.kt | 8 +- .../amethyst/ui/note/UserProfilePicture.kt | 15 ++- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 3 + .../commons/richtext/AnimatedMediaUrl.kt | 14 ++- commonsUI/ARCHITECTURE.md | 8 +- .../AnimatedImageAutoPlay.android.kt | 5 +- .../commons/ui/components/UserAvatar.kt | 102 +++++++----------- .../commons/ui/components/AvatarImage.ios.kt | 80 ++++++++++++++ .../AvatarImage.jvmAndroid.kt} | 40 ++----- .../ui/components/RobohashAsyncImage.kt | 40 ++++--- .../components/AnimatedImageAutoPlay.jvm.kt | 3 +- .../ui/chats/composer/GifPickerPanel.kt | 2 +- .../desktop/ui/media/AnimatedGifImage.kt | 7 -- .../desktop/ui/media/PictureDisplay.kt | 1 + .../desktop/ui/media/ZoomableImage.kt | 1 + .../amethyst/desktop/ui/note/NoteCard.kt | 2 +- .../desktop/ui/note/WoTBadgedAvatar.kt | 2 - 18 files changed, 189 insertions(+), 155 deletions(-) create mode 100644 commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt rename commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/{note/UserPictureImage.kt => components/AvatarImage.jvmAndroid.kt} (54%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt index 2774e6f8c0..3002e75f48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -26,9 +26,7 @@ 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 @@ -38,19 +36,12 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssem fun UserFinderFilterAssemblerSubscription( user: User, accountViewModel: AccountViewModel, -) = UserFinderFilterAssemblerSubscription( +) = SharedUserFinderSubscription( user, accountViewModel.account, accountViewModel.dataSources().userFinder, ) -@Composable -fun UserFinderFilterAssemblerSubscription( - user: User, - forAccount: Account, - dataSource: UserFinderFilterAssembler, -) = SharedUserFinderSubscription(user, forAccount, dataSource) - /** * Watches all the users that are cited in the parent post. * So that we load their relay lists and find posts diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 36fc70c585..d7af8ef884 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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" || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 3aa93de82f..165d78d292 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -40,12 +40,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User 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.note.UserPictureImage import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore @@ -533,12 +535,17 @@ fun InnerUserPicture( modifier: Modifier, accountViewModel: AccountViewModel, ) { - UserPictureImage( + UserAvatar( userHex = userHex, - userPicture = userPicture, - userName = userName, + 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) + }, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 13a092c162..7fbbf5fed1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -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, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt index 8f3a251674..7bdde9baf2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/AnimatedMediaUrl.kt @@ -20,11 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.richtext -/** True for GIF/AVIF urls, which may animate and so bypass still-image pipelines like the avatar thumbnail cache. */ -fun isAnimatedMediaUrl(url: String): Boolean = +/** 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) || - url.endsWith(".avif", 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) diff --git a/commonsUI/ARCHITECTURE.md b/commonsUI/ARCHITECTURE.md index 2830e8a38f..b693e06a2b 100644 --- a/commonsUI/ARCHITECTURE.md +++ b/commonsUI/ARCHITECTURE.md @@ -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 diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt index f93981c56b..4205e6d09a 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.android.kt @@ -23,6 +23,7 @@ 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 @@ -33,7 +34,9 @@ internal actual fun AnimatedImageAutoPlay( autoPlay: Boolean, ) { val resources = LocalContext.current.resources - val drawable = image?.asDrawable(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) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index 9a559ebde8..1e294c544f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -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` (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, +) diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt new file mode 100644 index 0000000000..b57daac73a --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt @@ -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, + ) + } +} diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserPictureImage.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.jvmAndroid.kt similarity index 54% rename from commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserPictureImage.kt rename to commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.jvmAndroid.kt index b3234d1f08..bd9f3ae050 100644 --- a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/UserPictureImage.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.jvmAndroid.kt @@ -18,53 +18,27 @@ * 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 +package com.vitorpamplona.amethyst.commons.ui.components -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.Dp -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.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.commons.ui.stringRes -/** - * A user's round avatar: their picture when [loadProfilePicture] allows it, otherwise (or while - * loading / on error) their robohash, or a plain face icon when [loadRobohash] is off. - * Animated pictures play only when [autoPlayGif]. - */ @Composable -fun UserPictureImage( +internal actual fun AvatarImage( userHex: String, - userPicture: String?, - userName: String?, - size: Dp, + pictureUrl: String?, + contentDescription: String?, modifier: Modifier, loadProfilePicture: Boolean, loadRobohash: Boolean, autoPlayGif: Boolean, ) { - val myImageModifier = - remember { - modifier.size(size).clip(shape = CircleShape) - } - RobohashFallbackAsyncImage( robot = userHex, - model = userPicture, - contentDescription = - if (userName != null) { - stringRes(id = Res.string.profile_image_of_user, userName) - } else { - stringRes(id = Res.string.profile_image) - }, - modifier = myImageModifier, + model = pictureUrl, + contentDescription = contentDescription, + modifier = modifier, contentScale = ContentScale.Crop, loadProfilePicture = loadProfilePicture, loadRobohash = loadRobohash, diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashAsyncImage.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashAsyncImage.kt index c1725fb28b..ff55834b65 100644 --- a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashAsyncImage.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashAsyncImage.kt @@ -54,7 +54,8 @@ 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 has no such drawable, so it is a no-op there. + * decoded `Animatable` drawable; desktop decodes only the first frame, so there [autoPlay] has + * no effect. */ @Composable internal expect fun AnimatedImageAutoPlay( @@ -100,6 +101,7 @@ fun RobohashFallbackAsyncImage( loadRobohash: Boolean, autoPlayGif: Boolean = true, ) { + val useThumbnailCache = LocalProfilePictureCache.current if (model != null && loadProfilePicture && isAnimatedMediaUrl(model)) { GifProfilePicture( userHex = robot, @@ -123,12 +125,13 @@ fun RobohashFallbackAsyncImage( } 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 @@ -204,19 +207,26 @@ fun GifProfilePicture( } 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) { diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt index 3edd5be51c..27474a2773 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AnimatedImageAutoPlay.jvm.kt @@ -28,5 +28,6 @@ internal actual fun AnimatedImageAutoPlay( image: Image?, autoPlay: Boolean, ) { - // Desktop decodes animated avatars without a controllable drawable; nothing to toggle. + // 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. } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt index 66fff8317f..4d9a099214 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifPickerPanel.kt @@ -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 /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt index 88f1abe5c2..b2013610dc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt @@ -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, val durations: List, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt index 59eb0475b6..a2eb2160c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt @@ -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 /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt index e26094d955..b77677c697 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 7c575eb267..35eb2de57b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt index 9e91f361b1..40953b745c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt @@ -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) { { From 7f4f3cab3b0784d7b8aa2741e701e66836bf9683 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 01:32:12 +0000 Subject: [PATCH 3/5] fix: read backup-conflict labels through LocalResources Android lint's LocalContextGetResourceValueCall fails the build on rememberPresentation: context.getString is not configuration-aware. LocalResources.current invalidates on configuration changes, so the remembered labels follow a locale change. main fails the same lint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../screen/loggedIn/backups/BackupConflictPresentation.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt index 6c7772ba36..58195a5d31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -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. */ From 78efe8901f3fb0ed1e76dbeaeec11355611a5137 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:17:29 +0000 Subject: [PATCH 4/5] refactor: share the per-note event observers The observeNote* family (note, event, reactions, zaps, reposts, replies, OTS, references, modifications, community approval) moves to commonsUI relayClient/event. Each takes the UserFinderAccount and the EventFinderFilterAssembler, defaulting to LocalUserFinderAccount and LocalEventFinder, so a shared composable needs only the note. The app's EventObservers keep their signatures and delegate with the account and data source from AccountViewModel, so call sites are unchanged and Activity roots without the locals keep working. isMinichatReply (pure quartz) moves to commons model/chats, and its test to commonTest with kotlin.test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../reqCommand/event/EventObservers.kt | 401 ++------------ .../chats/minichat/MinichatFeedViewModel.kt | 2 +- .../publicChannels/dal/ChannelFeedFilter.kt | 2 +- .../relayGroup/RelayGroupUnread.kt | 2 +- .../loggedIn/redirect/LoadRedirectScreen.kt | 2 +- .../commons/model}/chats/MinichatReply.kt | 2 +- .../commons/model}/chats/MinichatReplyTest.kt | 8 +- .../relayClient/event/EventObservers.kt | 516 ++++++++++++++++++ 9 files changed, 570 insertions(+), 367 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model}/chats/MinichatReply.kt (98%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn => commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model}/chats/MinichatReplyTest.kt (96%) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventObservers.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3ba243658a..85b79bd3c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index cde6322ad4..33e77422c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -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 { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = 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 { - val flow = remember(note) { note.flow().metadata.stateFlow } - return flow.collectAsStateWithLifecycle() -} - -@OptIn(ExperimentalCoroutinesApi::class) @Composable inline fun observeNoteEvent( note: Note, accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = sharedObserveNoteEvent(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 observeNoteAndMap( note: Note, accountViewModel: AccountViewModel, map: (Note) -> T, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = sharedObserveNoteAndMap(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 observeNoteEventAndMapNotNull( note: Note, accountViewModel: AccountViewModel, map: (T) -> U, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = sharedObserveNoteEventAndMapNotNull(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 observeNoteEventAndMap( note: Note, accountViewModel: AccountViewModel, map: (T?) -> U, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = sharedObserveNoteEventAndMap(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 { - // 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 = sharedObserveNoteHasEvent(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun observeNoteReplies( note: Note, accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = 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 { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = 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 { - 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 = sharedObserveNoteMinichatReplyCount(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun observeNoteReactions( note: Note, accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = 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 { - // 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 = sharedObserveNoteReactionCount(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun observeNoteZaps( note: Note, accountViewModel: AccountViewModel, -): State { - // 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 = sharedObserveNoteZaps(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun observeNoteReposts( note: Note, accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = 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 { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = 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 { - // 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 = sharedObserveNoteRepostCount(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun observeNoteReferences( note: Note, accountViewModel: AccountViewModel, -): State { - // 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 = sharedObserveNoteReferences(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun observeNoteOts( note: Note, accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) +): State = 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?> { - // Subscribe in the relay for changes in this note. - EventFinderFilterAssemblerSubscription(note, accountViewModel) - - return produceState?>(initialValue = null, note) { - note - .flow() - .edits - .stateFlow - .sample(500) - .mapLatest { note.textNoteModifications() } - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .collect { value = it } - } -} +): State?> = sharedObserveNoteModifications(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) @Composable fun observeCommunityApprovalNeedStatus( note: Note, community: Note, accountViewModel: AccountViewModel, -): State { - // 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 = sharedObserveCommunityApprovalNeedStatus(note, community, accountViewModel.account, accountViewModel.dataSources().eventFinder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt index 4c261853bd..4d26fdc2fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index b2f6b23c37..f656839204 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt index 37105e8a5c..3937effabe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt index 5c25c80a54..a2060f9e78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt index c6b8d8d4f9..1e97f7e0a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt similarity index 96% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt index acb9de2a98..27e0a33c10 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt @@ -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. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventObservers.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventObservers.kt new file mode 100644 index 0000000000..d105d02d99 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventObservers.kt @@ -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 { + // 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 { + val flow = remember(note) { note.flow().metadata.stateFlow } + return flow.collectAsStateWithLifecycle() +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +inline fun observeNoteEvent( + note: Note, + account: UserFinderAccount = LocalUserFinderAccount.current, + dataSource: EventFinderFilterAssembler = LocalEventFinder.current, +): State { + // 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 observeNoteAndMap( + note: Note, + account: UserFinderAccount = LocalUserFinderAccount.current, + dataSource: EventFinderFilterAssembler = LocalEventFinder.current, + map: (Note) -> T, +): State { + // 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 observeNoteEventAndMapNotNull( + note: Note, + account: UserFinderAccount = LocalUserFinderAccount.current, + dataSource: EventFinderFilterAssembler = LocalEventFinder.current, + map: (T) -> U, +): State { + // 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 observeNoteEventAndMap( + note: Note, + account: UserFinderAccount = LocalUserFinderAccount.current, + dataSource: EventFinderFilterAssembler = LocalEventFinder.current, + map: (T?) -> U, +): State { + // 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 { + // 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 { + // 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 { + // 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 { + 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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?> { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note, account, dataSource) + + return produceState?>(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 { + // 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) +} From bc44d87011425cc4aaff52f7ec687b07d7e9ff1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 03:27:03 +0000 Subject: [PATCH 5/5] test: wait for CalendarsViewModel scopes before resetMain The calendar ViewModel tests cleared their store and then reset Main, but clearing only requests cancellation. The model's flowOn producers on Dispatchers.Default could still be finishing, and handing their completion back to the Main collector after resetMain threw "The main looper is not available" onto a background thread. runTest then failed whichever test started next with UncaughtExceptionsBeforeTest (seen on CI as Nip46ConsentInfoBuilderTest). Both tests now join the models' viewModelScope jobs before resetMain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../calendar/CalendarsViewModelFlowTest.kt | 10 ++++++++ .../calendar/CalendarsViewModelTest.kt | 23 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt index 1a7920efdc..e504817753 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt @@ -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() } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt index 770e0add94..1358a9f67d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelTest.kt @@ -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() + @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 + } }