diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index 404b53df70..d991063521 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -85,7 +85,9 @@ fun DrawBanner( var sourceBounds by remember { mutableStateOf(null) } val bannerUrl = rememberFallbackUrlState(banner, bannerMeta?.fallback ?: emptyList()) - val hasPreviewHash = bannerMeta?.blurhash != null || bannerMeta?.thumbhash != null + // Whichever URL is on screen: the primary, or the fallback that replaced it. + val shownBanner = bannerUrl.url ?: banner + val hasPreviewHash = !bannerMeta?.blurhash.isNullOrBlank() || !bannerMeta?.thumbhash.isNullOrBlank() Box( modifier = @@ -97,7 +99,7 @@ fun DrawBanner( onClick = { zoomImageDialogOpen = true }, onLongClick = { scope.launch { - clipboardManager.setText(banner) + clipboardManager.setText(shownBanner) } }, ), @@ -125,7 +127,7 @@ fun DrawBanner( if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = RichTextParser.parseImageOrVideo(banner), + imageUrl = RichTextParser.parseImageOrVideo(shownBanner), sourceBounds = sourceBounds, onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/ServerResultPictureMeta.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/ServerResultPictureMeta.kt index 7d0764df7e..1a14b7264c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/ServerResultPictureMeta.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/ServerResultPictureMeta.kt @@ -31,7 +31,8 @@ fun UploadOrchestrator.OrchestratorResult.ServerResult.toPictureMeta(): PictureM url = url, mimeType = fileHeader.mimeType, hash = fileHeader.hash, - size = fileHeader.size, + // A server that reports no size leaves it at 0, which is not a size. + size = fileHeader.size.takeIf { it > 0 }, dimension = fileHeader.dim?.takeIf { it.hasSize() }, blurhash = fileHeader.blurHash?.blurhash, thumbhash = fileHeader.thumbHash?.thumbhash, diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadResultPictureMeta.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadResultPictureMeta.kt index 71e5686dfa..0c1421fb92 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadResultPictureMeta.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadResultPictureMeta.kt @@ -24,15 +24,18 @@ import com.vitorpamplona.quartz.nip68Picture.PictureMeta import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag /** - * The NIP-92 `imeta` description of an image uploaded to [url], for a kind 0 `picture` or - * `banner` (https://github.com/nostr-protocol/nips/pull/2494). + * The NIP-92 `imeta` description of an image uploaded to [url]: for a kind 0 `picture` or + * `banner` (https://github.com/nostr-protocol/nips/pull/2494) or a NIP-68 picture post. + * + * Every field comes from [UploadResult.metadata], computed on the bytes that left this machine, + * so hash, size, dimensions and blurhash all describe the same file. */ fun UploadResult.toPictureMeta(url: String): PictureMeta = PictureMeta( url = url, - mimeType = blossom.type ?: metadata.mimeType, - hash = blossom.sha256 ?: metadata.sha256, - size = (blossom.size ?: metadata.size).toInt(), + mimeType = metadata.mimeType, + hash = metadata.sha256, + size = metadata.size.takeIf { it in 1..Int.MAX_VALUE }?.toInt(), dimension = if (metadata.width != null && metadata.height != null && metadata.width > 0 && metadata.height > 0) { DimensionTag(metadata.width, metadata.height) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index 78723ecbef..088028fe84 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -109,18 +109,32 @@ fun UserAvatar( .clip(shape = CircleShape) } - val picture = rememberFallbackUrlState(pictureUrl, pictureFallbacks) + if (pictureFallbacks.isEmpty()) { + // Almost every profile: no state to keep and no error hook for the feed's avatars. + AvatarImage( + userHex = userHex, + pictureUrl = pictureUrl, + contentDescription = contentDescription, + modifier = avatarModifier, + loadProfilePicture = loadProfilePicture, + loadRobohash = loadRobohash, + autoPlayGif = autoPlayGif, + onError = null, + ) + } else { + val picture = rememberFallbackUrlState(pictureUrl, pictureFallbacks) - AvatarImage( - userHex = userHex, - pictureUrl = picture.url, - contentDescription = contentDescription, - modifier = avatarModifier, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, - autoPlayGif = autoPlayGif, - onError = picture::onError, - ) + AvatarImage( + userHex = userHex, + pictureUrl = picture.url, + contentDescription = contentDescription, + modifier = avatarModifier, + loadProfilePicture = loadProfilePicture, + loadRobohash = loadRobohash, + autoPlayGif = autoPlayGif, + onError = picture::onError, + ) + } } /** @@ -136,5 +150,5 @@ internal expect fun AvatarImage( loadProfilePicture: Boolean, loadRobohash: Boolean, autoPlayGif: Boolean, - onError: () -> Unit, + onError: (() -> Unit)?, ) diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt index 31cca35208..f80ec5a837 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.ios.kt @@ -43,7 +43,7 @@ internal actual fun AvatarImage( loadProfilePicture: Boolean, loadRobohash: Boolean, autoPlayGif: Boolean, - onError: () -> Unit, + onError: (() -> Unit)?, ) { if (pictureUrl != null && loadProfilePicture) { val fallbackPainter = @@ -61,7 +61,7 @@ internal actual fun AvatarImage( fallback = fallbackPainter, error = fallbackPainter, contentScale = ContentScale.Crop, - onError = { onError() }, + onError = onError?.let { onError -> { onError() } }, ) } else if (loadRobohash) { Image( diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.jvmAndroid.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.jvmAndroid.kt index b8c312fe39..ed6eec73d8 100644 --- a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.jvmAndroid.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/AvatarImage.jvmAndroid.kt @@ -33,7 +33,7 @@ internal actual fun AvatarImage( loadProfilePicture: Boolean, loadRobohash: Boolean, autoPlayGif: Boolean, - onError: () -> Unit, + onError: (() -> Unit)?, ) { RobohashFallbackAsyncImage( robot = userHex, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index c44212493f..1ed973ac40 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult +import com.vitorpamplona.amethyst.commons.service.upload.toPictureMeta import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.ImageCompressionStore @@ -1027,26 +1028,7 @@ private fun PostTypeSelector( } } -private fun buildPictureMetas(results: List): List = - results.mapNotNull { result -> - val url = result.blossom.url ?: return@mapNotNull null - val meta = result.metadata - com.vitorpamplona.quartz.nip68Picture.PictureMeta( - url = url, - mimeType = meta.mimeType, - blurhash = meta.blurhash, - dimension = - meta.width?.let { w -> - meta.height?.let { h -> - com.vitorpamplona.quartz.nip94FileMetadata.tags - .DimensionTag(w, h) - } - }, - hash = meta.sha256, - size = meta.size.toInt(), - thumbhash = meta.thumbhash, - ) - } +private fun buildPictureMetas(results: List): List = results.mapNotNull { result -> result.blossom.url?.let { result.toPictureMeta(it) } } private suspend fun publishPicture( description: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index a2dd1fa03a..42404a80fe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -271,8 +271,10 @@ class MetadataEvent( updateProfileImageMetas( previous = emptyArray(), - picture = currentMetadata[PictureTag.TAG_NAME]?.text, - banner = currentMetadata[BannerTag.TAG_NAME]?.text, + previousPicture = null, + previousBanner = null, + picture = (currentMetadata[PictureTag.TAG_NAME] as? JsonPrimitive)?.content, + banner = (currentMetadata[BannerTag.TAG_NAME] as? JsonPrimitive)?.content, pictureMeta = pictureMeta, bannerMeta = bannerMeta, ) @@ -307,6 +309,8 @@ class MetadataEvent( ): EventTemplate { // Tries to not delete any existing attribute that we do not work with. val currentMetadata = latest.contactMetadataJson()?.toMutableMap() ?: mutableMapOf() + val previousPicture = (currentMetadata[PictureTag.TAG_NAME] as? JsonPrimitive)?.content + val previousBanner = (currentMetadata[BannerTag.TAG_NAME] as? JsonPrimitive)?.content updateFieldsWeWorkWith( currentMetadata, @@ -334,11 +338,13 @@ class MetadataEvent( remove(IdentityClaimTag.TAG_NAME) claims(newClaims) - // NIP-92: keep the imetas of unchanged images, drop the ones no longer in use. + // NIP-92: keep the imetas of unchanged images, drop the ones of images no longer in use. updateProfileImageMetas( previous = latest.tags, - picture = currentMetadata[PictureTag.TAG_NAME]?.text, - banner = currentMetadata[BannerTag.TAG_NAME]?.text, + previousPicture = previousPicture, + previousBanner = previousBanner, + picture = (currentMetadata[PictureTag.TAG_NAME] as? JsonPrimitive)?.content, + banner = (currentMetadata[BannerTag.TAG_NAME] as? JsonPrimitive)?.content, pictureMeta = pictureMeta, bannerMeta = bannerMeta, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetas.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetas.kt index 422455618a..c9b865c568 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetas.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetas.kt @@ -30,8 +30,9 @@ import com.vitorpamplona.quartz.nip92IMeta.IMetaTag * NIP-92 `imeta` descriptions of the images a kind 0 names in its `picture` and `banner` * fields: https://github.com/nostr-protocol/nips/pull/2494 * - * An `imeta` describes a field only when its `url` is exactly that field's value; any other - * `imeta` on a kind 0 is ignored. + * An `imeta` describes a field only when its `url` is that field's value; any other `imeta` on a + * kind 0 is ignored. Both sides are compared trimmed, because profile values are trimmed + * everywhere they are read and written, and a URL cannot carry surrounding whitespace anyway. */ class ProfileImageMetas( val picture: PictureMeta?, @@ -45,16 +46,20 @@ class ProfileImageMetas( picture: String?, banner: String?, ): ProfileImageMetas { - if (picture.isNullOrBlank() && banner.isNullOrBlank()) return EMPTY + val pictureUrl = picture?.trim()?.ifEmpty { null } + val bannerUrl = banner?.trim()?.ifEmpty { null } + if (pictureUrl == null && bannerUrl == null) return EMPTY var pictureMeta: PictureMeta? = null var bannerMeta: PictureMeta? = null tags.fastForEach { tag -> - if (pictureMeta != null && bannerMeta != null) return@fastForEach + if (tag.isEmpty() || tag[0] != IMetaTag.TAG_NAME) return@fastForEach + if ((pictureUrl == null || pictureMeta != null) && (bannerUrl == null || bannerMeta != null)) return@fastForEach IMetaTag.parse(tag)?.forEach { imeta -> - if (pictureMeta == null && imeta.url == picture) pictureMeta = PictureMeta.parse(imeta) - if (bannerMeta == null && imeta.url == banner) bannerMeta = PictureMeta.parse(imeta) + val url = imeta.url.trim() + if (pictureMeta == null && url == pictureUrl) pictureMeta = PictureMeta.parse(imeta) + if (bannerMeta == null && url == bannerUrl) bannerMeta = PictureMeta.parse(imeta) } } @@ -71,38 +76,57 @@ fun MetadataEvent.profileImageMetas(metadata: UserMetadata? = contactMetaData()) ProfileImageMetas.parse(tags, metadata.picture, metadata.banner) } -/** The first url of an `imeta` tag, or null if [tag] is not one. */ -private fun imetaUrl(tag: Array): String? = IMetaTag.parse(tag)?.firstOrNull()?.url +/** The trimmed first url of an `imeta` tag, or null if [tag] is not one. */ +private fun imetaUrl(tag: Array): String? { + if (tag.isEmpty() || tag[0] != IMetaTag.TAG_NAME) return null + return IMetaTag + .parse(tag) + ?.firstOrNull() + ?.url + ?.trim() +} /** - * Rewrites the kind 0's `imeta` tags so they describe exactly the images it now names. + * Rewrites the kind 0's `imeta` tags after its `picture`/`banner` changed from + * [previousPicture]/[previousBanner] to [picture]/[banner]. * - * [previous] are the tags of the kind 0 being replaced: its `imeta`s for a `picture` or - * `banner` that did not change are carried over, and the ones for images no longer in use - * are dropped. A non-null [pictureMeta] / [bannerMeta] whose url is the current field value - * replaces whatever described that image before. + * Of the [previous] kind 0's `imeta`s, the ones for an image still in use are carried over and the + * ones for an image the profile stopped using are dropped. An `imeta` whose url was never the + * picture or banner is not ours to judge, so it is kept as is. A non-null [pictureMeta] / + * [bannerMeta] whose url is the new field value replaces whatever described that image before. */ fun TagArrayBuilder.updateProfileImageMetas( previous: TagArray, + previousPicture: String?, + previousBanner: String?, picture: String?, banner: String?, pictureMeta: PictureMeta? = null, bannerMeta: PictureMeta? = null, ) { - remove(IMetaTag.TAG_NAME) + val pictureUrl = picture?.trim()?.ifEmpty { null } + val bannerUrl = banner?.trim()?.ifEmpty { null } - val newPicture = pictureMeta?.takeIf { it.url == picture } - val newBanner = bannerMeta?.takeIf { it.url == banner && it.url != newPicture?.url } + val newPicture = pictureMeta?.takeIf { pictureUrl != null && it.url.trim() == pictureUrl } + val newBanner = bannerMeta?.takeIf { bannerUrl != null && it.url.trim() == bannerUrl && it.url.trim() != newPicture?.url?.trim() } - val replaced = setOfNotNull(newPicture?.url, newBanner?.url) - val inUse = setOfNotNull(picture?.ifBlank { null }, banner?.ifBlank { null }) + val replaced = setOfNotNull(newPicture?.url?.trim(), newBanner?.url?.trim()) + val inUse = setOfNotNull(pictureUrl, bannerUrl) + val retired = setOfNotNull(previousPicture?.trim(), previousBanner?.trim()) - inUse val carried = mutableSetOf() + remove(IMetaTag.TAG_NAME) + previous.fastForEach { tag -> val url = imetaUrl(tag) ?: return@fastForEach - if (url in inUse && url !in replaced && carried.add(url)) { - add(tag) - } + val keep = + when (url) { + in replaced -> false + in inUse -> carried.add(url) + in retired -> false + else -> true + } + if (keep) add(tag) } newPicture?.let { add(it.toIMetaArray()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetasTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetasTest.kt index a963928e45..e068188e3e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetasTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/ProfileImageMetasTest.kt @@ -255,4 +255,37 @@ class ProfileImageMetasTest { assertEquals(1, event.tags.count { it[0] == "imeta" }) assertMeta(pictureMeta, event.profileImageMetas().banner) } + + @Test + fun updateKeepsImetaThatWasNeverPictureOrBanner() { + val unrelated = arrayOf("imeta", "url https://other.example/badge.png", "dim 64x64") + val first = + event( + "{\"picture\":\"$picture\"}", + arrayOf("imeta", "url $picture", "dim 400x400"), + unrelated, + ) + + val second = signer.sign(MetadataEvent.updateFromPast(first, picture = "https://blossom.example/new.png", createdAt = 1740669817)) + + assertNull(second.profileImageMetas().picture) + val imetas = second.tags.filter { it[0] == "imeta" } + assertEquals(1, imetas.size) + assertContentEquals(unrelated, imetas[0]) + } + + @Test + fun matchesIgnoringSurroundingWhitespace() { + val event = + event( + "{\"picture\":\"$picture \"}", + arrayOf("imeta", "url $picture", "dim 400x400"), + ) + + assertNotNull(event.profileImageMetas().picture) + + // A name-only edit leaves the untrimmed picture alone and must keep its imeta. + val second = signer.sign(MetadataEvent.updateFromPast(event, name = "luna", createdAt = 1740669817)) + assertEquals(1, second.tags.count { it[0] == "imeta" }) + } }