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)) + } }