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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6
This commit is contained in:
Claude
2026-09-24 00:21:14 +00:00
parent 0a5aed7ec5
commit ef845067a4
18 changed files with 189 additions and 155 deletions
@@ -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
@@ -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" ||
@@ -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 =
@@ -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,
)
@@ -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)
+4 -4
View File
@@ -46,11 +46,11 @@ is specific to the UI module.
| Source set | For |
|---------------|-----|
| `commonMain` | Composables, icons, theme, Coil fetchers, `composeResources`. Gated by `verifyKmpPurity` like `commons`. |
| `jvmAndroid` | Markdown renderer (`ui/markdown`), Coil-OkHttp + Blossom read-auth fetcher, the `viewModel()` helper. |
| `jvmMain` | Desktop Coil bridge (`CoilImageBridge.jvm.kt`), `compose.desktop.currentOs`. `dependsOn(jvmAndroid)` + `skikoMain`. |
| `androidMain` | Android Coil bridge. `dependsOn(jvmAndroid)`. |
| `jvmAndroid` | Markdown renderer (`ui/markdown`), Coil-OkHttp + Blossom read-auth fetcher, the `viewModel()` helper, and the avatar/image engine (`ui/components/RobohashAsyncImage.kt`: `RobohashFallbackAsyncImage`, `GifProfilePicture`) behind the `UserAvatar` `AvatarImage` actual. For a user avatar call the commonMain `UserAvatar`; don't add another avatar composable. |
| `jvmMain` | Desktop Coil bridge (`CoilImageBridge.jvm.kt`), a no-op `AnimatedImageAutoPlay` (Coil decodes only the first frame), `compose.desktop.currentOs`. `dependsOn(jvmAndroid)` + `skikoMain`. |
| `androidMain` | Android Coil bridge, the `AnimatedImageAutoPlay` actual that starts/stops GIF/AVIF drawables. `dependsOn(jvmAndroid)`. |
| `skikoMain` | `org.jetbrains.skia` pixel helpers shared by desktop JVM + iOS (`SkiaBitmapConverter`). |
| `iosMain` | iOS Coil bridge. Compile-only spike today. |
| `iosMain` | iOS Coil bridge, a plain `AsyncImage` `AvatarImage` actual. Compile-only spike today. |
## Tooling that points here
@@ -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) {
@@ -20,28 +20,16 @@
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
/**
* Wrapper class for profile picture URLs that signals Coil to use the thumbnail
@@ -54,7 +42,16 @@ data class ProfilePictureUrl(
)
/**
* Shared avatar component that displays a user's profile picture with Robohash fallback.
* True when the host's Coil pipeline registers a `Fetcher.Factory<ProfilePictureUrl>` (the
* avatar thumbnail cache) and the local Blossom cache bridge that strips the profile-picture
* request marker. Avatars then load http(s) pictures through that cache. Hosts without them
* (desktop, iOS) keep the default `false`, so avatars load the plain URL.
*/
val LocalProfilePictureCache = staticCompositionLocalOf { false }
/**
* The shared user avatar: the profile picture clipped to a circle, with a Robohash (or a generic
* face icon) while it loads, when it fails, or when pictures are turned off.
*
* @param userHex The user's public key hex (used for Robohash generation)
* @param pictureUrl Optional URL to the user's profile picture
@@ -63,7 +60,8 @@ data class ProfilePictureUrl(
* @param contentDescription Accessibility description
* @param loadProfilePicture Whether to load the profile picture (false = show robohash only)
* @param loadRobohash Whether to generate robohash (false = show generic icon)
* @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads
* @param autoPlayGif Whether animated (GIF/AVIF) pictures play. Only Android can animate them;
* desktop shows their first frame either way.
* @param badge Optional overlay drawn on top of the avatar (bottom-right by
* convention). Used by Desktop for the WoT trust-score chip; Android call
* sites leave it null. When null the avatar renders as before (no extra
@@ -78,7 +76,7 @@ fun UserAvatar(
contentDescription: String? = null,
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
autoPlayGif: Boolean = true,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
if (badge != null) {
@@ -91,7 +89,7 @@ fun UserAvatar(
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
autoPlayGif = autoPlayGif,
badge = null,
)
badge()
@@ -106,53 +104,25 @@ fun UserAvatar(
.clip(shape = CircleShape)
}
val imageModel: Any? =
if (pictureUrl != null && useThumbnailCache) {
ProfilePictureUrl(pictureUrl)
} else {
pictureUrl
}
if (imageModel != null && loadProfilePicture) {
// Show profile picture with robohash/icon as fallback
val fallbackPainter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight),
)
} else {
rememberMaterialSymbolPainter(MaterialSymbols.Face)
}
AsyncImage(
model = imageModel,
contentDescription = contentDescription,
modifier = avatarModifier,
placeholder = fallbackPainter,
fallback = fallbackPainter,
error = fallbackPainter,
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
alpha = DefaultAlpha,
colorFilter = null,
filterQuality = DrawScope.DefaultFilterQuality,
)
} else if (loadRobohash) {
// Show robohash only
Image(
imageVector = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight),
contentDescription = contentDescription,
modifier = avatarModifier,
contentScale = ContentScale.Crop,
)
} else {
// Show generic icon
Image(
painter = rememberMaterialSymbolPainter(MaterialSymbols.Face),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground),
modifier = avatarModifier,
contentScale = ContentScale.Crop,
)
}
AvatarImage(
userHex = userHex,
pictureUrl = pictureUrl,
contentDescription = contentDescription,
modifier = avatarModifier,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
autoPlayGif = autoPlayGif,
)
}
/** Draws the avatar picture into an already sized and clipped [modifier]. */
@Composable
internal expect fun AvatarImage(
userHex: String,
pictureUrl: String?,
contentDescription: String?,
modifier: Modifier,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
autoPlayGif: Boolean,
)
@@ -0,0 +1,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,
)
}
}
@@ -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,
@@ -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) {
@@ -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.
}
@@ -49,10 +49,10 @@ import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import kotlinx.coroutines.delay
/**
@@ -51,13 +51,6 @@ private const val MIN_FRAME_DURATION_MS = 20
private val gifHttpClient get() = DesktopHttpClient.currentClient()
fun isAnimatedGifUrl(url: String): Boolean {
val lower = url.lowercase()
return lower.endsWith(".gif") ||
lower.contains(".gif?") ||
lower.contains(".gif#")
}
private class GifFrames(
val frames: List<ImageBitmap>,
val durations: List<Int>,
@@ -40,6 +40,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
/**
@@ -37,6 +37,7 @@ import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssem
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.richtext.isAnimatedGifUrl
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
@@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -54,7 +54,6 @@ fun WoTBadgedAvatar(
contentDescription: String? = null,
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
) {
val service = LocalWoTService.current
val ready = LocalWoTReady.current
@@ -75,7 +74,6 @@ fun WoTBadgedAvatar(
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
badge =
if (score > 0) {
{