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:
+11
-5
@@ -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(
|
||||
|
||||
+68
-10
@@ -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("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
|
||||
/**
|
||||
* Converts this media content into a Coil/ExoPlayer-friendly model string.
|
||||
*
|
||||
* When the local-Blossom-cache bridge is active and the content has a
|
||||
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>`
|
||||
* 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:<sha256>.<ext>?xs=<originalHostBase>&as=<authorPubKey>`
|
||||
* 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?,
|
||||
|
||||
+7
-2
@@ -50,6 +50,7 @@ class RichTextParser {
|
||||
eventTags: Map<String, IMetaTag>,
|
||||
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<String>,
|
||||
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 }
|
||||
|
||||
+54
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user