mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+1
-10
@@ -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
|
||||
|
||||
+2
-6
@@ -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,
|
||||
)
|
||||
|
||||
+10
-4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+36
-66
@@ -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,
|
||||
)
|
||||
|
||||
+80
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+7
-33
@@ -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,
|
||||
+25
-15
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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.
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
-7
@@ -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>,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-2
@@ -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) {
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user