feat(blossom): add as= author hint and profile-pictures-only mode
- `as=<authorPubKey>`: 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/<sha>.jpg).
This commit is contained in:
+2
@@ -41,6 +41,7 @@ fun TranslatableRichTextViewer(
|
|||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
id: String,
|
id: String,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) = ExpandableRichTextViewer(
|
) = ExpandableRichTextViewer(
|
||||||
@@ -52,6 +53,7 @@ fun TranslatableRichTextViewer(
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
id,
|
id,
|
||||||
callbackUri,
|
callbackUri,
|
||||||
|
authorPubKey,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.transform
|
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
|
// toggle flips or the probe transitions up/down so stale entries don't
|
||||||
// outlive the underlying decision.
|
// outlive the underlying decision.
|
||||||
applicationIOScope.launch {
|
applicationIOScope.launch {
|
||||||
sessionManager.accountContent.collectLatest { state ->
|
sessionManager.accountContent.collectLatest { state ->
|
||||||
if (state is AccountState.LoggedIn) {
|
if (state is AccountState.LoggedIn) {
|
||||||
state.account.settings.useLocalBlossomCache
|
merge(
|
||||||
.drop(1)
|
state.account.settings.useLocalBlossomCache
|
||||||
.collect {
|
.drop(1),
|
||||||
blossomResolver.uriToUrlCache.evictAll()
|
state.account.settings.localBlossomCacheProfilePicturesOnly
|
||||||
blossomResolver.blossomHitCache.cache.evictAll()
|
.drop(1),
|
||||||
localBlossomCacheProbe.invalidate()
|
).collect {
|
||||||
}
|
blossomResolver.uriToUrlCache.evictAll()
|
||||||
|
blossomResolver.blossomHitCache.cache.evictAll()
|
||||||
|
localBlossomCacheProbe.invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ private object PrefKeys {
|
|||||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||||
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
|
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
|
||||||
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
|
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_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||||
@@ -348,6 +349,7 @@ object LocalPreferences {
|
|||||||
|
|
||||||
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
|
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
|
||||||
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
|
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_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
|
||||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.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 stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
|
||||||
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, 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 hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||||
@@ -624,6 +627,7 @@ object LocalPreferences {
|
|||||||
defaultFileServer = defaultFileServer.await(),
|
defaultFileServer = defaultFileServer.await(),
|
||||||
stripLocationOnUpload = stripLocationOnUpload,
|
stripLocationOnUpload = stripLocationOnUpload,
|
||||||
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
|
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
|
||||||
|
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
|
||||||
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
|
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
|
||||||
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
|
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
|
||||||
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
|
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ class AccountSettings(
|
|||||||
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
||||||
var stripLocationOnUpload: Boolean = true,
|
var stripLocationOnUpload: Boolean = true,
|
||||||
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
|
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||||
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||||
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||||
@@ -411,6 +412,13 @@ class AccountSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
|
||||||
|
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
|
||||||
|
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
|
||||||
|
saveAccountSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---
|
// ---
|
||||||
// list names
|
// list names
|
||||||
// ---
|
// ---
|
||||||
|
|||||||
@@ -33,12 +33,16 @@ object CachedRichTextParser {
|
|||||||
content: String,
|
content: String,
|
||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
callbackUri: String?,
|
callbackUri: String?,
|
||||||
|
authorPubKey: String?,
|
||||||
): Int {
|
): Int {
|
||||||
var result = content.hashCode()
|
var result = content.hashCode()
|
||||||
result = 31 * result + tags.lists.hashCode()
|
result = 31 * result + tags.lists.hashCode()
|
||||||
if (callbackUri != null) {
|
if (callbackUri != null) {
|
||||||
result = 31 * result + callbackUri.hashCode()
|
result = 31 * result + callbackUri.hashCode()
|
||||||
}
|
}
|
||||||
|
if (authorPubKey != null) {
|
||||||
|
result = 31 * result + authorPubKey.hashCode()
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,19 +50,21 @@ object CachedRichTextParser {
|
|||||||
content: String,
|
content: String,
|
||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri)]
|
authorPubKey: String? = null,
|
||||||
|
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)]
|
||||||
|
|
||||||
fun parseText(
|
fun parseText(
|
||||||
content: String,
|
content: String,
|
||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
): RichTextViewerState {
|
): RichTextViewerState {
|
||||||
val key = hashCodeCache(content, tags, callbackUri)
|
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
|
||||||
val cached = richTextCache[key]
|
val cached = richTextCache[key]
|
||||||
return if (cached != null) {
|
return if (cached != null) {
|
||||||
cached
|
cached
|
||||||
} else {
|
} else {
|
||||||
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
|
val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
||||||
richTextCache.put(key, newUrls)
|
richTextCache.put(key, newUrls)
|
||||||
newUrls
|
newUrls
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -124,7 +124,10 @@ fun MediaServersScaffold(
|
|||||||
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
||||||
val enabled by accountViewModel.account.settings.useLocalBlossomCache
|
val enabled by accountViewModel.account.settings.useLocalBlossomCache
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
val probeAvailable by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
|
val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
@@ -162,5 +165,30 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
|||||||
onCheckedChange = { accountViewModel.account.settings.changeUseLocalBlossomCache(it) },
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -64,6 +64,7 @@ fun ExpandableRichTextViewer(
|
|||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
id: String,
|
id: String,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -100,6 +101,7 @@ fun ExpandableRichTextViewer(
|
|||||||
tags,
|
tags,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
callbackUri,
|
callbackUri,
|
||||||
|
authorPubKey,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ fun RichTextViewer(
|
|||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -147,7 +148,7 @@ fun RichTextViewer(
|
|||||||
if (remember(content) { isMarkdown(content) }) {
|
if (remember(content) { isMarkdown(content) }) {
|
||||||
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
|
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
|
||||||
} else {
|
} 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,
|
quotesLeft: Int,
|
||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
if (canPreview) {
|
if (canPreview) {
|
||||||
RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier ->
|
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
|
||||||
if (paragraph is ImageGalleryParagraph) {
|
if (paragraph is ImageGalleryParagraph) {
|
||||||
ImageGallery(
|
ImageGallery(
|
||||||
images = paragraph,
|
images = paragraph,
|
||||||
@@ -375,7 +377,7 @@ private fun RenderRegular(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier ->
|
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
|
||||||
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
|
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
|
||||||
RenderWordWithoutPreview(
|
RenderWordWithoutPreview(
|
||||||
word,
|
word,
|
||||||
@@ -412,9 +414,10 @@ fun RenderRegular(
|
|||||||
content: String,
|
content: String,
|
||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
renderParagraph: @Composable (ParagraphState, state: RichTextViewerState, Dp, modifier: Modifier) -> Unit,
|
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)
|
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
|
||||||
|
|
||||||
|
|||||||
+41
-4
@@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
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.graphics.vector.rememberVectorPainter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.asDrawable
|
import coil3.asDrawable
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import coil3.compose.SubcomposeAsyncImage
|
import coil3.compose.SubcomposeAsyncImage
|
||||||
import coil3.compose.SubcomposeAsyncImageContent
|
import coil3.compose.SubcomposeAsyncImageContent
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
|
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.robohash.CachedRobohash
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
|
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.isLight
|
||||||
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
|
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
|
@Composable
|
||||||
fun RobohashAsyncImage(
|
fun RobohashAsyncImage(
|
||||||
@@ -88,16 +120,21 @@ fun RobohashFallbackAsyncImage(
|
|||||||
loadRobohash: Boolean,
|
loadRobohash: Boolean,
|
||||||
autoPlayGif: Boolean = true,
|
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(
|
GifProfilePicture(
|
||||||
userHex = robot,
|
userHex = robot,
|
||||||
userPicture = model,
|
userPicture = bridgedModel,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
loadRobohash = loadRobohash,
|
loadRobohash = loadRobohash,
|
||||||
autoPlay = autoPlayGif,
|
autoPlay = autoPlayGif,
|
||||||
)
|
)
|
||||||
} else if (model != null && loadProfilePicture) {
|
} else if (bridgedModel != null && loadProfilePicture) {
|
||||||
val painter =
|
val painter =
|
||||||
if (loadRobohash) {
|
if (loadRobohash) {
|
||||||
rememberVectorPainter(
|
rememberVectorPainter(
|
||||||
@@ -111,7 +148,7 @@ fun RobohashFallbackAsyncImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ProfilePictureUrl(model),
|
model = ProfilePictureUrl(bridgedModel),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
placeholder = painter,
|
placeholder = painter,
|
||||||
|
|||||||
@@ -550,6 +550,7 @@ fun CrossfadeToDisplayComment(
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
comment,
|
comment,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ private fun RenderOptionAfterVote(
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
baseNote.idHex + poolOption.descriptor,
|
baseNote.idHex + poolOption.descriptor,
|
||||||
baseNote.toNostrUri(),
|
baseNote.toNostrUri(),
|
||||||
|
baseNote.author?.pubkeyHex,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
|||||||
+24
-6
@@ -189,13 +189,32 @@ class AccountViewModel(
|
|||||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `true` when both the per-account toggle is enabled AND the local
|
* `true` when feed/note media (images and videos in `MediaUrlContent`)
|
||||||
* Blossom cache HEAD probe currently sees `127.0.0.1:24242` as
|
* should be routed through the local Blossom cache. Requires the master
|
||||||
* available. UI call sites use this to decide whether to convert plain
|
* toggle on, the probe up, AND the profile-pictures-only restriction
|
||||||
* http(s) URLs (with imeta sha256) into `blossom:` URIs so the request
|
* to be off.
|
||||||
* routes through the local cache.
|
|
||||||
*/
|
*/
|
||||||
val useLocalBlossomBridge: StateFlow<Boolean> =
|
val useLocalBlossomBridge: StateFlow<Boolean> =
|
||||||
|
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<Boolean> =
|
||||||
try {
|
try {
|
||||||
combine(
|
combine(
|
||||||
account.settings.useLocalBlossomCache,
|
account.settings.useLocalBlossomCache,
|
||||||
@@ -206,7 +225,6 @@ class AccountViewModel(
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
} catch (e: UninitializedPropertyAccessException) {
|
} catch (e: UninitializedPropertyAccessException) {
|
||||||
// Mock/test instances don't initialise Amethyst.instance.
|
|
||||||
MutableStateFlow(false)
|
MutableStateFlow(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -62,6 +62,7 @@ fun RenderRegularTextNote(
|
|||||||
backgroundColor = bgColor,
|
backgroundColor = bgColor,
|
||||||
id = note.idHex,
|
id = note.idHex,
|
||||||
callbackUri = note.toNostrUri(),
|
callbackUri = note.toNostrUri(),
|
||||||
|
authorPubKey = note.author?.pubkeyHex,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -810,6 +810,8 @@
|
|||||||
<string name="use_local_blossom_cache_caption">When a Blossom cache is running on this device (port 24242), route image and video downloads through it.</string>
|
<string name="use_local_blossom_cache_caption">When a Blossom cache is running on this device (port 24242), route image and video downloads through it.</string>
|
||||||
<string name="local_blossom_cache_detected">Local cache detected on port 24242.</string>
|
<string name="local_blossom_cache_detected">Local cache detected on port 24242.</string>
|
||||||
<string name="local_blossom_cache_not_detected">Local cache not detected on port 24242.</string>
|
<string name="local_blossom_cache_not_detected">Local cache not detected on port 24242.</string>
|
||||||
|
<string name="local_blossom_cache_profile_pics_only">Only cache profile pictures</string>
|
||||||
|
<string name="local_blossom_cache_profile_pics_only_caption">Restrict the local cache to profile pictures. Feed images and videos will be fetched directly from the original servers.</string>
|
||||||
|
|
||||||
<string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
<string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
||||||
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
||||||
|
|||||||
+2
@@ -53,6 +53,7 @@ fun TranslatableRichTextViewer(
|
|||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
id: String,
|
id: String,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -70,6 +71,7 @@ fun TranslatableRichTextViewer(
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
id,
|
id,
|
||||||
callbackUri,
|
callbackUri,
|
||||||
|
authorPubKey,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-5
@@ -42,6 +42,7 @@ abstract class MediaUrlContent(
|
|||||||
val uri: String? = null,
|
val uri: String? = null,
|
||||||
val mimeType: String? = null,
|
val mimeType: String? = null,
|
||||||
thumbhash: String? = null,
|
thumbhash: String? = null,
|
||||||
|
val authorPubKey: String? = null,
|
||||||
) : BaseMediaContent(description, dim, blurhash, thumbhash)
|
) : BaseMediaContent(description, dim, blurhash, thumbhash)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@@ -55,7 +56,8 @@ open class MediaUrlImage(
|
|||||||
val contentWarning: String? = null,
|
val contentWarning: String? = null,
|
||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
thumbhash: 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(
|
class EncryptedMediaUrlImage(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -70,7 +72,8 @@ class EncryptedMediaUrlImage(
|
|||||||
val encryptionKey: ByteArray,
|
val encryptionKey: ByteArray,
|
||||||
val encryptionNonce: ByteArray,
|
val encryptionNonce: ByteArray,
|
||||||
thumbhash: String? = null,
|
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
|
@Immutable
|
||||||
open class MediaUrlPdf(
|
open class MediaUrlPdf(
|
||||||
@@ -82,7 +85,8 @@ open class MediaUrlPdf(
|
|||||||
uri: String? = null,
|
uri: String? = null,
|
||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
thumbhash: 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
|
@Immutable
|
||||||
open class MediaUrlVideo(
|
open class MediaUrlVideo(
|
||||||
@@ -98,7 +102,8 @@ open class MediaUrlVideo(
|
|||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
thumbhash: String? = null,
|
thumbhash: String? = null,
|
||||||
val isLiveStream: Boolean = false,
|
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
|
@Immutable
|
||||||
class EncryptedMediaUrlVideo(
|
class EncryptedMediaUrlVideo(
|
||||||
@@ -116,7 +121,8 @@ class EncryptedMediaUrlVideo(
|
|||||||
val encryptionKey: ByteArray,
|
val encryptionKey: ByteArray,
|
||||||
val encryptionNonce: ByteArray,
|
val encryptionNonce: ByteArray,
|
||||||
thumbhash: String? = null,
|
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
|
@Immutable
|
||||||
abstract class MediaPreloadedContent(
|
abstract class MediaPreloadedContent(
|
||||||
|
|||||||
+68
-10
@@ -23,40 +23,98 @@ package com.vitorpamplona.amethyst.commons.richtext
|
|||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||||
|
|
||||||
private val sha256HexRegex = Regex("[0-9a-f]{64}")
|
private val sha256HexRegex = Regex("[0-9a-f]{64}")
|
||||||
|
private val sha256InPathRegex = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts this media content into a Coil/ExoPlayer-friendly model string.
|
* Converts this media content into a Coil/ExoPlayer-friendly model string.
|
||||||
*
|
*
|
||||||
* When the local-Blossom-cache bridge is active and the content has a
|
* When the local-Blossom-cache bridge is active and the content has a
|
||||||
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>`
|
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>&as=<authorPubKey>`
|
||||||
* URI. The Coil pipeline will recognise the scheme and route the request
|
* URI. The Coil pipeline recognises the scheme and routes the request
|
||||||
* through `BlossomServerResolver`, which in turn short-circuits to the
|
* through `BlossomServerResolver`, which short-circuits to the local cache
|
||||||
* local cache at `127.0.0.1:24242`.
|
* at `127.0.0.1:24242`.
|
||||||
*
|
*
|
||||||
* Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI,
|
* Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI,
|
||||||
* or a live stream) returns the original URL unchanged so today's
|
* or a live stream) returns the original URL unchanged so today's
|
||||||
* direct-to-CDN behaviour is preserved.
|
* direct-to-CDN behaviour is preserved.
|
||||||
*/
|
*/
|
||||||
fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String {
|
fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String =
|
||||||
if (!useLocalBlossomBridge) return url
|
bridgeUrl(
|
||||||
if (this is MediaUrlVideo && isLiveStream) return url
|
url = url,
|
||||||
val sha = hash?.lowercase() ?: return url
|
useBridge = useLocalBlossomBridge,
|
||||||
if (!sha256HexRegex.matches(sha)) return url
|
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("blossom:", ignoreCase = true)) return url
|
||||||
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", 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 ext = guessExtension(url, mimeType)
|
||||||
val hostBase = extractHostBase(url) ?: return url
|
val hostBase = extractHostBase(url) ?: return url
|
||||||
|
|
||||||
|
val authors =
|
||||||
|
authorPubKey
|
||||||
|
?.lowercase()
|
||||||
|
?.takeIf { sha256HexRegex.matches(it) }
|
||||||
|
?.let { listOf(it) }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
return BlossomUri(
|
return BlossomUri(
|
||||||
sha256 = sha,
|
sha256 = sha,
|
||||||
extension = ext,
|
extension = ext,
|
||||||
servers = listOf(hostBase),
|
servers = listOf(hostBase),
|
||||||
authors = emptyList(),
|
authors = authors,
|
||||||
size = null,
|
size = null,
|
||||||
).toUriString()
|
).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(
|
private fun guessExtension(
|
||||||
url: String,
|
url: String,
|
||||||
mimeType: String?,
|
mimeType: String?,
|
||||||
|
|||||||
+7
-2
@@ -50,6 +50,7 @@ class RichTextParser {
|
|||||||
eventTags: Map<String, IMetaTag>,
|
eventTags: Map<String, IMetaTag>,
|
||||||
description: String?,
|
description: String?,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
|
authorPubKey: String? = null,
|
||||||
): MediaUrlContent? {
|
): MediaUrlContent? {
|
||||||
val frags = Nip54InlineMetadata().parse(fullUrl)
|
val frags = Nip54InlineMetadata().parse(fullUrl)
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ class RichTextParser {
|
|||||||
uri = callbackUri,
|
uri = callbackUri,
|
||||||
mimeType = contentType,
|
mimeType = contentType,
|
||||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||||
|
authorPubKey = authorPubKey,
|
||||||
)
|
)
|
||||||
} else if (isVideo) {
|
} else if (isVideo) {
|
||||||
MediaUrlVideo(
|
MediaUrlVideo(
|
||||||
@@ -99,6 +101,7 @@ class RichTextParser {
|
|||||||
uri = callbackUri,
|
uri = callbackUri,
|
||||||
mimeType = contentType,
|
mimeType = contentType,
|
||||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||||
|
authorPubKey = authorPubKey,
|
||||||
)
|
)
|
||||||
} else if (isPdf) {
|
} else if (isPdf) {
|
||||||
MediaUrlPdf(
|
MediaUrlPdf(
|
||||||
@@ -110,6 +113,7 @@ class RichTextParser {
|
|||||||
uri = callbackUri,
|
uri = callbackUri,
|
||||||
mimeType = contentType,
|
mimeType = contentType,
|
||||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||||
|
authorPubKey = authorPubKey,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -160,16 +164,17 @@ class RichTextParser {
|
|||||||
content: String,
|
content: String,
|
||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
callbackUri: String?,
|
callbackUri: String?,
|
||||||
|
authorPubKey: String? = null,
|
||||||
): RichTextViewerState {
|
): RichTextViewerState {
|
||||||
val imetas = tags.lists.imetasByUrl()
|
val imetas = tags.lists.imetasByUrl()
|
||||||
val urlSet = UrlParser().parseValidUrls(content)
|
val urlSet = UrlParser().parseValidUrls(content)
|
||||||
|
|
||||||
val mediaContents =
|
val mediaContents =
|
||||||
urlSet.withScheme.mapNotNull { fullUrl ->
|
urlSet.withScheme.mapNotNull { fullUrl ->
|
||||||
createMediaContent(fullUrl, imetas, content, callbackUri)
|
createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey)
|
||||||
} +
|
} +
|
||||||
urlSet.withoutScheme.mapNotNull { fullUrl ->
|
urlSet.withoutScheme.mapNotNull { fullUrl ->
|
||||||
createMediaContent(fullUrl, imetas, content, callbackUri)
|
createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaForPager = mediaContents.associateBy { it.url }
|
val mediaForPager = mediaContents.associateBy { it.url }
|
||||||
|
|||||||
+54
@@ -90,4 +90,58 @@ class MediaUrlContentExtTest {
|
|||||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||||
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user