From 119ddf7cadcb77a4d1eb6c5cdbedc10ba347eff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 08:45:02 +0000 Subject: [PATCH] feat(blossom): add as= author hint and profile-pictures-only mode - `as=`: when converting plain http(s) imeta URLs into `blossom:` URIs we now include the note author's pubkey, letting the local cache consult their kind:10063 BUD-03 server list on miss. Plumbed via RichTextParser/CachedRichTextParser/RichTextViewer/ ExpandableRichTextViewer/TranslatableRichTextViewer. - Profile-pictures-only mode: a new per-account toggle that restricts the bridge to profile pictures (RobohashFallbackAsyncImage). When on, feed images and videos skip the bridge entirely. Profile pictures recover their sha256 from the URL path when present (covers Blossom-hosted avatars like https://nostr.build/i/.jpg). --- .../components/TranslatableRichTextViewer.kt | 2 + .../com/vitorpamplona/amethyst/AppModules.kt | 20 +++-- .../amethyst/LocalPreferences.kt | 4 + .../amethyst/model/AccountSettings.kt | 8 ++ .../amethyst/service/CachedRichTextParser.kt | 12 ++- .../mediaServers/AllMediaServersScreen.kt | 30 ++++++- .../ui/components/ExpandableRichTextViewer.kt | 2 + .../amethyst/ui/components/RichTextViewer.kt | 11 ++- .../ui/components/RobohashAsyncImage.kt | 45 ++++++++++- .../amethyst/ui/note/MultiSetCompose.kt | 1 + .../amethyst/ui/note/ZapPollNote.kt | 1 + .../ui/screen/loggedIn/AccountViewModel.kt | 30 +++++-- .../chats/feed/types/RenderRegularTextNote.kt | 1 + amethyst/src/main/res/values/strings.xml | 2 + .../components/TranslatableRichTextViewer.kt | 2 + .../commons/richtext/MediaContentModels.kt | 16 ++-- .../commons/richtext/MediaUrlContentExt.kt | 78 ++++++++++++++++--- .../commons/richtext/RichTextParser.kt | 9 ++- .../richtext/MediaUrlContentExtTest.kt | 54 +++++++++++++ 19 files changed, 285 insertions(+), 43 deletions(-) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index b52b7d568b..b6c79e0a67 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -41,6 +41,7 @@ fun TranslatableRichTextViewer( backgroundColor: MutableState, id: String, callbackUri: String? = null, + authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, ) = ExpandableRichTextViewer( @@ -52,6 +53,7 @@ fun TranslatableRichTextViewer( backgroundColor, id, callbackUri, + authorPubKey, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0ef73c462c..764240d344 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -110,6 +110,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.transform @@ -588,19 +589,22 @@ class AppModules( } } - // Evict the BlossomServerResolver URL cache whenever the local-cache + // Evict the BlossomServerResolver URL cache whenever either local-cache // toggle flips or the probe transitions up/down so stale entries don't // outlive the underlying decision. applicationIOScope.launch { sessionManager.accountContent.collectLatest { state -> if (state is AccountState.LoggedIn) { - state.account.settings.useLocalBlossomCache - .drop(1) - .collect { - blossomResolver.uriToUrlCache.evictAll() - blossomResolver.blossomHitCache.cache.evictAll() - localBlossomCacheProbe.invalidate() - } + merge( + state.account.settings.useLocalBlossomCache + .drop(1), + state.account.settings.localBlossomCacheProfilePicturesOnly + .drop(1), + ).collect { + blossomResolver.uriToUrlCache.evictAll() + blossomResolver.blossomHitCache.cache.evictAll() + localBlossomCacheProbe.invalidate() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index b499a3c7c8..8d2e92badf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -96,6 +96,7 @@ private object PrefKeys { const val DEFAULT_FILE_SERVER = "defaultFileServer" const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache" + const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -348,6 +349,7 @@ object LocalPreferences { putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value) + putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -516,6 +518,7 @@ object LocalPreferences { val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true) + val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) @@ -624,6 +627,7 @@ object LocalPreferences { defaultFileServer = defaultFileServer.await(), stripLocationOnUpload = stripLocationOnUpload, useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache), + localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly), defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index fd8846a739..1bacad5340 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -150,6 +150,7 @@ class AccountSettings( var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], var stripLocationOnUpload: Boolean = true, val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true), + val localBlossomCacheProfilePicturesOnly: MutableStateFlow = MutableStateFlow(false), val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -411,6 +412,13 @@ class AccountSettings( } } + fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) { + if (localBlossomCacheProfilePicturesOnly.value != enabled) { + localBlossomCacheProfilePicturesOnly.tryEmit(enabled) + saveAccountSettings() + } + } + // --- // list names // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index 70c802ac9f..ef442e3c5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -33,12 +33,16 @@ object CachedRichTextParser { content: String, tags: ImmutableListOfLists, callbackUri: String?, + authorPubKey: String?, ): Int { var result = content.hashCode() result = 31 * result + tags.lists.hashCode() if (callbackUri != null) { result = 31 * result + callbackUri.hashCode() } + if (authorPubKey != null) { + result = 31 * result + authorPubKey.hashCode() + } return result } @@ -46,19 +50,21 @@ object CachedRichTextParser { content: String, tags: ImmutableListOfLists, callbackUri: String? = null, - ): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri)] + authorPubKey: String? = null, + ): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)] fun parseText( content: String, tags: ImmutableListOfLists, callbackUri: String? = null, + authorPubKey: String? = null, ): RichTextViewerState { - val key = hashCodeCache(content, tags, callbackUri) + val key = hashCodeCache(content, tags, callbackUri, authorPubKey) val cached = richTextCache[key] return if (cached != null) { cached } else { - val newUrls = RichTextParser().parseText(content, tags, callbackUri) + val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey) richTextCache.put(key, newUrls) newUrls } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index bee3b5e000..25bd2eeb09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -124,7 +124,10 @@ fun MediaServersScaffold( private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { val enabled by accountViewModel.account.settings.useLocalBlossomCache .collectAsStateWithLifecycle() - val probeAvailable by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle() + val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly + .collectAsStateWithLifecycle() + val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics + .collectAsStateWithLifecycle() Column( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -162,5 +165,30 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { onCheckedChange = { accountViewModel.account.settings.changeUseLocalBlossomCache(it) }, ) } + + if (enabled) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(id = R.string.local_blossom_cache_profile_pics_only), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringRes(id = R.string.local_blossom_cache_profile_pics_only_caption), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + Switch( + checked = profilePicturesOnly, + onCheckedChange = { + accountViewModel.account.settings.changeLocalBlossomCacheProfilePicturesOnly(it) + }, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index ea1eb47d8b..a2e6d52ca9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -64,6 +64,7 @@ fun ExpandableRichTextViewer( backgroundColor: MutableState, id: String, callbackUri: String? = null, + authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -100,6 +101,7 @@ fun ExpandableRichTextViewer( tags, backgroundColor, callbackUri, + authorPubKey, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index c1166d1e82..ed5559c0f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -140,6 +140,7 @@ fun RichTextViewer( tags: ImmutableListOfLists, backgroundColor: MutableState, callbackUri: String? = null, + authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -147,7 +148,7 @@ fun RichTextViewer( if (remember(content) { isMarkdown(content) }) { RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav) } else { - RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav) + RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav) } } } @@ -347,11 +348,12 @@ private fun RenderRegular( quotesLeft: Int, backgroundColor: MutableState, callbackUri: String? = null, + authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { if (canPreview) { - RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier -> + RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier -> if (paragraph is ImageGalleryParagraph) { ImageGallery( images = paragraph, @@ -375,7 +377,7 @@ private fun RenderRegular( } } } else { - RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier -> + RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier -> RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> RenderWordWithoutPreview( word, @@ -412,9 +414,10 @@ fun RenderRegular( content: String, tags: ImmutableListOfLists, callbackUri: String? = null, + authorPubKey: String? = null, renderParagraph: @Composable (ParagraphState, state: RichTextViewerState, Dp, modifier: Modifier) -> Unit, ) { - val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) } + val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri, authorPubKey)) } val spaceWidth = measureSpaceWidth(LocalTextStyle.current) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index ac4a0badb3..4177ab05b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -38,17 +39,48 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.asDrawable import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter +import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl +import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +private fun rememberLocalBlossomBridgeForProfilePics(): Boolean { + val sessionManager = + try { + Amethyst.instance.sessionManager + } catch (e: UninitializedPropertyAccessException) { + return false + } + val probe = Amethyst.instance.localBlossomCacheProbe + val flow = + remember { + combine( + sessionManager.accountContent.flatMapLatest { state -> + if (state is AccountState.LoggedIn) state.account.settings.useLocalBlossomCache else flowOf(false) + }, + probe.available, + ) { toggle, probeUp -> toggle && probeUp } + } + val state by flow.collectAsStateWithLifecycle(initialValue = false) + return state +} @Composable fun RobohashAsyncImage( @@ -88,16 +120,21 @@ fun RobohashFallbackAsyncImage( loadRobohash: Boolean, autoPlayGif: Boolean = true, ) { - if (model != null && loadProfilePicture && isGifUrl(model)) { + val useBridge = rememberLocalBlossomBridgeForProfilePics() + val bridgedModel = + remember(model, robot, useBridge) { + bridgeProfilePictureUrl(model, useBridge, robot) + } + if (bridgedModel != null && loadProfilePicture && isGifUrl(bridgedModel)) { GifProfilePicture( userHex = robot, - userPicture = model, + userPicture = bridgedModel, contentDescription = contentDescription, modifier = modifier, loadRobohash = loadRobohash, autoPlay = autoPlayGif, ) - } else if (model != null && loadProfilePicture) { + } else if (bridgedModel != null && loadProfilePicture) { val painter = if (loadRobohash) { rememberVectorPainter( @@ -111,7 +148,7 @@ fun RobohashFallbackAsyncImage( } AsyncImage( - model = ProfilePictureUrl(model), + model = ProfilePictureUrl(bridgedModel), contentDescription = contentDescription, modifier = modifier, placeholder = painter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index d3346f3f53..5c2cbd4b5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -550,6 +550,7 @@ fun CrossfadeToDisplayComment( backgroundColor, comment, null, + null, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt index 3bfc938fbc..ad9f78accf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt @@ -425,6 +425,7 @@ private fun RenderOptionAfterVote( backgroundColor, baseNote.idHex + poolOption.descriptor, baseNote.toNostrUri(), + baseNote.author?.pubkeyHex, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index d69dc5ba19..9a86ab8037 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -189,13 +189,32 @@ class AccountViewModel( val feedStates = AccountFeedContentStates(account, viewModelScope) /** - * `true` when both the per-account toggle is enabled AND the local - * Blossom cache HEAD probe currently sees `127.0.0.1:24242` as - * available. UI call sites use this to decide whether to convert plain - * http(s) URLs (with imeta sha256) into `blossom:` URIs so the request - * routes through the local cache. + * `true` when feed/note media (images and videos in `MediaUrlContent`) + * should be routed through the local Blossom cache. Requires the master + * toggle on, the probe up, AND the profile-pictures-only restriction + * to be off. */ val useLocalBlossomBridge: StateFlow = + try { + combine( + account.settings.useLocalBlossomCache, + account.settings.localBlossomCacheProfilePicturesOnly, + Amethyst.instance.localBlossomCacheProbe.available, + ) { toggle, profileOnly, probeUp -> toggle && probeUp && !profileOnly }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + } catch (e: UninitializedPropertyAccessException) { + MutableStateFlow(false) + } + + /** + * `true` when profile pictures should be routed through the local + * Blossom cache. Requires only the master toggle and the probe to be + * up; the profile-pictures-only restriction does not gate this flow. + */ + val useLocalBlossomBridgeForProfilePics: StateFlow = try { combine( account.settings.useLocalBlossomCache, @@ -206,7 +225,6 @@ class AccountViewModel( false, ) } catch (e: UninitializedPropertyAccessException) { - // Mock/test instances don't initialise Amethyst.instance. MutableStateFlow(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index e12d86eff3..ba32f860f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -62,6 +62,7 @@ fun RenderRegularTextNote( backgroundColor = bgColor, id = note.idHex, callbackUri = note.toNostrUri(), + authorPubKey = note.author?.pubkeyHex, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3ba8f80272..f4b5471741 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -810,6 +810,8 @@ When a Blossom cache is running on this device (port 24242), route image and video downloads through it. Local cache detected on port 24242. Local cache not detected on port 24242. + Only cache profile pictures + Restrict the local cache to profile pictures. Feed images and videos will be fetched directly from the original servers. You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓ You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 004d1bdd78..58b3c6232c 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -53,6 +53,7 @@ fun TranslatableRichTextViewer( backgroundColor: MutableState, id: String, callbackUri: String? = null, + authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -70,6 +71,7 @@ fun TranslatableRichTextViewer( backgroundColor, id, callbackUri, + authorPubKey, accountViewModel, nav, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 0ab08e7269..1d2a534741 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -42,6 +42,7 @@ abstract class MediaUrlContent( val uri: String? = null, val mimeType: String? = null, thumbhash: String? = null, + val authorPubKey: String? = null, ) : BaseMediaContent(description, dim, blurhash, thumbhash) @Immutable @@ -55,7 +56,8 @@ open class MediaUrlImage( val contentWarning: String? = null, mimeType: String? = null, thumbhash: String? = null, -) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash) + authorPubKey: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey) class EncryptedMediaUrlImage( url: String, @@ -70,7 +72,8 @@ class EncryptedMediaUrlImage( val encryptionKey: ByteArray, val encryptionNonce: ByteArray, thumbhash: String? = null, -) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType, thumbhash) + authorPubKey: String? = null, +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType, thumbhash, authorPubKey) @Immutable open class MediaUrlPdf( @@ -82,7 +85,8 @@ open class MediaUrlPdf( uri: String? = null, mimeType: String? = null, thumbhash: String? = null, -) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash) + authorPubKey: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey) @Immutable open class MediaUrlVideo( @@ -98,7 +102,8 @@ open class MediaUrlVideo( mimeType: String? = null, thumbhash: String? = null, val isLiveStream: Boolean = false, -) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash) + authorPubKey: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey) @Immutable class EncryptedMediaUrlVideo( @@ -116,7 +121,8 @@ class EncryptedMediaUrlVideo( val encryptionKey: ByteArray, val encryptionNonce: ByteArray, thumbhash: String? = null, -) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash) + authorPubKey: String? = null, +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey) @Immutable abstract class MediaPreloadedContent( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index 3b5a07f90b..d0eae4ade1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -23,40 +23,98 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri private val sha256HexRegex = Regex("[0-9a-f]{64}") +private val sha256InPathRegex = Regex("(?.?xs=` - * URI. The Coil pipeline will recognise the scheme and route the request - * through `BlossomServerResolver`, which in turn short-circuits to the - * local cache at `127.0.0.1:24242`. + * known sha256 hash, returns a `blossom:.?xs=&as=` + * URI. The Coil pipeline recognises the scheme and routes the request + * through `BlossomServerResolver`, which short-circuits to the local cache + * at `127.0.0.1:24242`. * * Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI, * or a live stream) returns the original URL unchanged so today's * direct-to-CDN behaviour is preserved. */ -fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String { - if (!useLocalBlossomBridge) return url - if (this is MediaUrlVideo && isLiveStream) return url - val sha = hash?.lowercase() ?: return url - if (!sha256HexRegex.matches(sha)) return url +fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String = + bridgeUrl( + url = url, + useBridge = useLocalBlossomBridge, + explicitHash = hash, + mimeType = mimeType, + authorPubKey = authorPubKey, + skipBridge = this is MediaUrlVideo && isLiveStream, + ) + +/** + * Bridge entry point for raw URL strings (e.g. profile pictures) where the + * hash isn't available on a structured model. Tries to recover the sha256 + * from the URL path itself; falls back to the original URL when no hash + * can be determined. + * + * @param authorPubKey 64-char lowercase hex pubkey to send as `as=` so the + * local cache can consult that author's BUD-03 server list. + */ +fun bridgeProfilePictureUrl( + url: String?, + useBridge: Boolean, + authorPubKey: String? = null, +): String? { + if (url == null) return null + return bridgeUrl( + url = url, + useBridge = useBridge, + explicitHash = null, + mimeType = null, + authorPubKey = authorPubKey, + skipBridge = false, + ) +} + +private fun bridgeUrl( + url: String, + useBridge: Boolean, + explicitHash: String?, + mimeType: String?, + authorPubKey: String?, + skipBridge: Boolean, +): String { + if (!useBridge || skipBridge) return url if (url.startsWith("blossom:", ignoreCase = true)) return url if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url + val sha = + explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } + ?: extractSha256FromUrlPath(url) + ?: return url + val ext = guessExtension(url, mimeType) val hostBase = extractHostBase(url) ?: return url + val authors = + authorPubKey + ?.lowercase() + ?.takeIf { sha256HexRegex.matches(it) } + ?.let { listOf(it) } + ?: emptyList() + return BlossomUri( sha256 = sha, extension = ext, servers = listOf(hostBase), - authors = emptyList(), + authors = authors, size = null, ).toUriString() } +private fun extractSha256FromUrlPath(url: String): String? { + val pathPart = url.substringBefore('?').substringBefore('#') + val match = sha256InPathRegex.find(pathPart) ?: return null + return match.value.lowercase() +} + private fun guessExtension( url: String, mimeType: String?, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 94f4119fe6..0353bdc1ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -50,6 +50,7 @@ class RichTextParser { eventTags: Map, description: String?, callbackUri: String? = null, + authorPubKey: String? = null, ): MediaUrlContent? { val frags = Nip54InlineMetadata().parse(fullUrl) @@ -87,6 +88,7 @@ class RichTextParser { uri = callbackUri, mimeType = contentType, thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), + authorPubKey = authorPubKey, ) } else if (isVideo) { MediaUrlVideo( @@ -99,6 +101,7 @@ class RichTextParser { uri = callbackUri, mimeType = contentType, thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), + authorPubKey = authorPubKey, ) } else if (isPdf) { MediaUrlPdf( @@ -110,6 +113,7 @@ class RichTextParser { uri = callbackUri, mimeType = contentType, thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), + authorPubKey = authorPubKey, ) } else { null @@ -160,16 +164,17 @@ class RichTextParser { content: String, tags: ImmutableListOfLists, callbackUri: String?, + authorPubKey: String? = null, ): RichTextViewerState { val imetas = tags.lists.imetasByUrl() val urlSet = UrlParser().parseValidUrls(content) val mediaContents = urlSet.withScheme.mapNotNull { fullUrl -> - createMediaContent(fullUrl, imetas, content, callbackUri) + createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey) } + urlSet.withoutScheme.mapNotNull { fullUrl -> - createMediaContent(fullUrl, imetas, content, callbackUri) + createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey) } val mediaForPager = mediaContents.associateBy { it.url } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index b6da876a8d..cbca9375eb 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -90,4 +90,58 @@ class MediaUrlContentExtTest { val result = image.toCoilModel(useLocalBlossomBridge = true) assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result") } + + @Test + fun authorPubKeyAddedAsAsParam() { + val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" + val image = + MediaUrlImage( + url = "https://cdn.example.com/foo.jpg", + hash = sha, + authorPubKey = authorPub, + ) + val result = image.toCoilModel(useLocalBlossomBridge = true) + assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com&as=$authorPub", result) + } + + @Test + fun invalidAuthorPubKeyDropped() { + val image = + MediaUrlImage( + url = "https://cdn.example.com/foo.jpg", + hash = sha, + authorPubKey = "not-a-pubkey", + ) + val result = image.toCoilModel(useLocalBlossomBridge = true) + assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result) + } + + @Test + fun bridgeProfilePictureUrlNullReturnsNull() { + assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true)) + } + + @Test + fun bridgeProfilePictureUrlOffReturnsOriginal() { + assertEquals( + "https://cdn.example.com/avatar.jpg", + bridgeProfilePictureUrl("https://cdn.example.com/avatar.jpg", useBridge = false), + ) + } + + @Test + fun bridgeProfilePictureUrlExtractsShaFromPath() { + val url = "https://nostr.build/i/$sha.jpg" + val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" + assertEquals( + "blossom:$sha.jpg?xs=https://nostr.build&as=$authorPub", + bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub), + ) + } + + @Test + fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() { + val url = "https://nostr.build/avatar.jpg" + assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) + } }