From 3d225423efae31e4110695770d402250eb587bbf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 11 Dec 2023 12:51:53 -0500 Subject: [PATCH] Adds support for nip54 --- .../amethyst/service/CachedRichTextParser.kt | 34 +++++++- .../amethyst/ui/actions/NewPostView.kt | 8 +- .../amethyst/ui/actions/NewPostViewModel.kt | 85 +++++++++++++++++-- .../amethyst/ui/components/MarkdownParser.kt | 2 +- .../amethyst/ui/components/RichTextViewer.kt | 10 +++ .../ui/components/ZoomableContentView.kt | 5 +- .../amethyst/ui/note/NoteCompose.kt | 3 +- .../quartz/events/ChannelMessageEvent.kt | 2 +- .../quartz/events/ClassifiedsEvent.kt | 10 ++- .../events/LiveActivitiesChatMessageEvent.kt | 2 +- .../quartz/events/PollNoteEvent.kt | 2 +- .../quartz/events/TextNoteEvent.kt | 2 +- 12 files changed, 143 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index d036c124a..0afc00bb0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo import com.vitorpamplona.amethyst.ui.components.hashTagsPattern import com.vitorpamplona.amethyst.ui.components.imageExtensions +import com.vitorpamplona.amethyst.ui.components.removeQueryParams import com.vitorpamplona.amethyst.ui.components.tagIndex import com.vitorpamplona.amethyst.ui.components.videoExtensions import com.vitorpamplona.quartz.events.ImmutableListOfLists @@ -21,6 +22,8 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet +import java.net.URI +import java.net.URLDecoder import java.util.regex.Pattern @Immutable @@ -85,11 +88,26 @@ class RichTextParser() { } val imagesForPager = urlSet.mapNotNull { fullUrl -> - val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val removedParamsFromUrl = removeQueryParams(fullUrl) if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableUrlImage(fullUrl) + val frags = URI(fullUrl).fragments() + println("Image $fullUrl $frags") + ZoomableUrlImage( + url = fullUrl, + description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") }, + hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") }, + blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") }, + dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") } + ) } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableUrlVideo(fullUrl) + val frags = URI(fullUrl).fragments() + ZoomableUrlVideo( + url = fullUrl, + description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") }, + hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") }, + dim = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") }, + uri = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") } + ) } else { null } @@ -110,6 +128,16 @@ class RichTextParser() { ) } + private fun URI.fragments(): Map { + if (rawFragment == null) return emptyMap() + return rawFragment.split('&').associate { + val parts = it.split('=') + val name = parts.firstOrNull() ?: "" + val value = parts.getOrNull(1) ?: "" + Pair(name, value) + } + } + private fun findTextSegments(content: String, images: Set, urls: Set, emojis: Map, tags: ImmutableListOfLists): ImmutableList { var paragraphSegments = persistentListOf() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 171264e77..5d94d12d3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -460,8 +460,14 @@ fun NewPostView( if (myUrlPreview != null) { Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { if (isValidURL(myUrlPreview)) { - val removedParamsFromUrl = + val removedParamsFromUrl = if (myUrlPreview.contains("?")) { myUrlPreview.split("?")[0].lowercase() + } else if (myUrlPreview.contains("#")) { + myUrlPreview.split("#")[0].lowercase() + } else { + myUrlPreview + } + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { AsyncImage( model = myUrlPreview, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index e60118266..115e909c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import java.net.URLEncoder enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -376,7 +377,7 @@ open class NewPostViewModel() : ViewModel() { onReady = { fileUri, contentType, size -> if (server.isNip95) { contentResolver.openInputStream(fileUri)?.use { - createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList) + createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent) } } else { viewModelScope.launch(Dispatchers.IO) { @@ -400,11 +401,12 @@ open class NewPostViewModel() : ViewModel() { sensitiveContent = sensitiveContent ) } else { - val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1) - - isUploadingImage = false - message = TextFieldValue(message.text + "\n" + url) - urlPreview = findUrlInMessage() + noNIP94( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent + ) } } catch (e: Exception) { Log.e( @@ -652,7 +654,8 @@ open class NewPostViewModel() : ViewModel() { account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> isUploadingImage = false nip94attachments = nip94attachments + event - message = TextFieldValue(message.text + "\n" + imageUrl) + val contentWarning = if (sensitiveContent) "" else null + message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning)) urlPreview = findUrlInMessage() } }, @@ -665,12 +668,76 @@ open class NewPostViewModel() : ViewModel() { ) } + suspend fun noNIP94( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String?, + sensitiveContent: Boolean + ) { + // Images don't seem to be ready immediately after upload + val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1) + val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Server failed to return a url") + } + return + } + + FileHeader.prepare( + fileUrl = imageUrl, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { header: FileHeader -> + isUploadingImage = false + val contentWarning = if (sensitiveContent) "" else null + message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning)) + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + + fun addInlineMetadataAsNIP54( + imageUrl: String, + dim: String?, + m: String?, + alt: String?, + blurHash: String?, + x: String?, + sensitiveContent: String? + ): String { + val extension = listOfNotNull( + m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" }, + dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" }, + alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" }, + blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" }, + x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" }, + sensitiveContent?.ifBlank { null }?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" } + ).joinToString("&") + + return if (imageUrl.contains("#")) { + "$imageUrl&$extension" + } else { + "$imageUrl#$extension" + } + } + fun createNIP95Record( bytes: ByteArray, mimeType: String?, alt: String?, - sensitiveContent: Boolean, - relayList: List? = null + sensitiveContent: Boolean ) { viewModelScope.launch(Dispatchers.IO) { FileHeader.prepare( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt index fb8b93013..bdc25ab82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt @@ -93,7 +93,7 @@ class MarkdownParser { content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() + val removedParamsFromUrl = removeQueryParams(word) if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { returnContent += "![]($word) " } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index e71bda130..b248c4a1a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -95,6 +95,16 @@ val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", " val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) +fun removeQueryParams(fullUrl: String): String { + return if (fullUrl.contains("?")) { + fullUrl.split("?")[0].lowercase() + } else if (fullUrl.contains("#")) { + fullUrl.split("#")[0].lowercase() + } else { + fullUrl + } +} + fun isValidURL(url: String?): Boolean { return try { URL(url).toURI() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index cb543a4e4..8495e06f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -182,7 +182,7 @@ class ZoomableLocalVideo( ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) fun figureOutMimeType(fullUrl: String): ZoomableContent { - val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val removedParamsFromUrl = removeQueryParams(fullUrl) val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } @@ -389,6 +389,7 @@ private fun UrlImageView( } if (showImage.value) { + /* AsyncImage( model = content.url, contentDescription = content.description, @@ -398,6 +399,8 @@ private fun UrlImageView( painterState.value = it } ) + + */ } AddedImageFeatures( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a5275afea..93f9c947c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo import com.vitorpamplona.amethyst.ui.components.figureOutMimeType import com.vitorpamplona.amethyst.ui.components.imageExtensions +import com.vitorpamplona.amethyst.ui.components.removeQueryParams import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader @@ -3454,7 +3455,7 @@ fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: Acco val hash = event.hash() val dimensions = event.dimensions() val description = event.alt() ?: event.content - val isImage = imageExtensions.any { fullUrl.split("?")[0].lowercase().endsWith(it) } + val isImage = imageExtensions.any { removeQueryParams(fullUrl).lowercase().endsWith(it) } val uri = note.toNostrUri() mutableStateOf( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index b84e3bbd4..1d3d1f152 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -63,7 +63,7 @@ class ChannelMessageEvent( } nip94attachments?.let { it.forEach { - tags.add(arrayOf("nip94", it.toJson())) + //tags.add(arrayOf("nip94", it.toJson())) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index b893d5f79..895379301 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -119,7 +119,13 @@ class ClassifiedsEvent( tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } findURLs(message).forEach { - val removedParamsFromUrl = it.split("?")[0].lowercase() + val removedParamsFromUrl = if (it.contains("?")) + it.split("?")[0].lowercase() + else if (it.contains("#")) + it.split("#")[0].lowercase() + else + it + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { tags.add(arrayOf("image", it)) } @@ -136,7 +142,7 @@ class ClassifiedsEvent( } nip94attachments?.let { it.forEach { - tags.add(arrayOf("nip94", it.toJson())) + //tags.add(arrayOf("nip94", it.toJson())) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index 7fae80a7d..eeca36235 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -83,7 +83,7 @@ class LiveActivitiesChatMessageEvent( } nip94attachments?.let { it.forEach { - tags.add(arrayOf("nip94", it.toJson())) + //tags.add(arrayOf("nip94", it.toJson())) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index a42daa59c..732c72e6f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -102,7 +102,7 @@ class PollNoteEvent( } nip94attachments?.let { it.forEach { - tags.add(arrayOf("nip94", it.toJson())) + //tags.add(arrayOf("nip94", it.toJson())) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 37ed4e7c8..97e67c494 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -99,7 +99,7 @@ class TextNoteEvent( } nip94attachments?.let { it.forEach { - tags.add(arrayOf("nip94", it.toJson())) + //tags.add(arrayOf("nip94", it.toJson())) } }