From ef845067a48d26da69584b98a3323a53ccbd3b21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:21:14 +0000 Subject: [PATCH] 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) { {