From e56377f8c3f8e8e53844d1cc56786f3072876e42 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 1 Feb 2024 18:31:28 -0500 Subject: [PATCH] Adds support for NIP-29 in public messages and new DMs. NIP-54 stays in NIP-54 --- README.md | 1 + .../vitorpamplona/amethyst/model/Account.kt | 14 +- .../amethyst/service/CachedRichTextParser.kt | 37 ++-- .../amethyst/ui/actions/NewPostViewModel.kt | 190 ++++-------------- .../amethyst/ui/components/RichTextViewer.kt | 7 +- .../ui/note/ChatroomMessageCompose.kt | 14 +- .../ui/screen/loggedIn/ChannelScreen.kt | 7 + .../ui/screen/loggedIn/ChatroomScreen.kt | 6 + .../vitorpamplona/quartz/encoders/Nip29.kt | 76 +++++++ .../vitorpamplona/quartz/encoders/Nip54.kt | 38 +++- .../quartz/events/ChannelMessageEvent.kt | 7 +- .../quartz/events/ChatMessageEvent.kt | 9 + .../quartz/events/FileHeaderEvent.kt | 22 +- .../events/LiveActivitiesChatMessageEvent.kt | 7 +- .../quartz/events/NIP24Factory.kt | 2 + .../quartz/events/PollNoteEvent.kt | 7 +- .../quartz/events/PrivateDmEvent.kt | 28 ++- .../quartz/events/TextNoteEvent.kt | 7 +- 18 files changed, 277 insertions(+), 202 deletions(-) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip29.kt rename app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt => quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54.kt (65%) diff --git a/README.md b/README.md index 4ff182488..abab32af1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Delegated Event Signing (NIP-26, Will not implement) - [x] Text Note References (NIP-27) - [x] Public Chats (NIP-28) +- [x] Inline Metadata (NIP-29) - [x] Custom Emoji (NIP-30) - [x] Event kind summaries (NIP-31) - [ ] Labeling (NIP-32) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 5ad77ecac..8ef1ab90c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1325,7 +1325,7 @@ class Account( directMentions: Set, relayList: List? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1381,7 +1381,7 @@ class Account( zapRaiserAmount: Long? = null, relayList: List? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1428,7 +1428,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1461,7 +1461,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1495,6 +1495,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, ) { sendPrivateMessage( message, @@ -1505,6 +1506,7 @@ class Account( wantsToMarkAsSensitive, zapRaiserAmount, geohash, + nip94attachments, ) } @@ -1517,6 +1519,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1533,6 +1536,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, signer = signer, advertiseNip18 = false, ) { @@ -1551,6 +1555,7 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, ) { if (!isWriteable()) return @@ -1567,6 +1572,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, signer = signer, ) { broadcastPrivately(it) 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 6f3046af9..e0f944138 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -34,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison import com.vitorpamplona.amethyst.ui.components.tagIndex import com.vitorpamplona.amethyst.ui.components.videoExtensions +import com.vitorpamplona.quartz.encoders.Nip29 +import com.vitorpamplona.quartz.encoders.Nip54 +import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.ImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -94,27 +97,33 @@ val HTTPRegex = .toRegex(RegexOption.IGNORE_CASE) class RichTextParser() { - fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? { + fun parseMediaUrl( + fullUrl: String, + tags: ImmutableListOfLists, + ): ZoomableUrlContent? { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip44UrlParser().parse(fullUrl) + val frags = Nip54().parse(fullUrl) + val tags = Nip29().parse(fullUrl, tags.lists) + ZoomableUrlImage( url = fullUrl, - description = frags["alt"], - hash = frags["x"], - blurhash = frags["blurhash"], - dim = frags["dim"], - contentWarning = frags["content-warning"], + description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], + blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], + dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + contentWarning = frags["content-warning"] ?: tags["content-warning"], ) } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip44UrlParser().parse(fullUrl) + val frags = Nip54().parse(fullUrl) + val tags = Nip29().parse(fullUrl, tags.lists) ZoomableUrlVideo( url = fullUrl, - description = frags["alt"], - hash = frags["x"], - blurhash = frags["blurhash"], - dim = frags["dim"], - contentWarning = frags["content-warning"], + description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], + blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], + dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + contentWarning = frags["content-warning"] ?: tags["content-warning"], ) } else { null @@ -146,7 +155,7 @@ class RichTextParser() { } val imagesForPager = - urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url } + urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url } val imageList = imagesForPager.values.toList() val emojiMap = 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 1bc8a5fbd..e137d1cfd 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 @@ -71,7 +71,6 @@ 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, @@ -89,6 +88,7 @@ open class NewPostViewModel() : ViewModel() { var pTags by mutableStateOf?>(null) var eTags by mutableStateOf?>(null) + var imetaTags = mutableStateListOf>() var nip94attachments by mutableStateOf>(emptyList()) var nip95attachments by @@ -266,44 +266,46 @@ open class NewPostViewModel() : ViewModel() { val urls = findURLs(tagger.message) val usedAttachments = nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } - usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } + // Doesn't send as nip94 yet because we don't know if it makes sense. + // usedAttachments.forEach { account?.sendHeader(it, relayList, {}) } if (originalNote?.channelHex() != null) { if (originalNote is AddressableEvent && originalNote?.address() != null) { account?.sendLiveMessage( - tagger.message, - originalNote?.address()!!, - tagger.eTags, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, + message = tagger.message, + toChannel = originalNote?.address()!!, + replyTo = tagger.eTags, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, nip94attachments = usedAttachments, ) } else { account?.sendChannelMessage( - tagger.message, - tagger.channelHex!!, - tagger.eTags, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, + message = tagger.message, + toChannel = tagger.channelHex!!, + replyTo = tagger.eTags, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, nip94attachments = usedAttachments, ) } } else if (originalNote?.event is PrivateDmEvent) { account?.sendPrivateMessage( - tagger.message, - originalNote!!.author!!, - originalNote!!, - tagger.pTags, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - geoHash, + message = tagger.message, + toUser = originalNote!!.author!!, + replyingTo = originalNote!!, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + geohash = geoHash, + nip94attachments = usedAttachments, ) } else if (originalNote?.event is ChatMessageEvent) { val receivers = @@ -324,6 +326,7 @@ open class NewPostViewModel() : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, + nip94attachments = usedAttachments, ) } else if (!dmUsers.isNullOrEmpty()) { if (nip24 || dmUsers.size > 1) { @@ -337,6 +340,7 @@ open class NewPostViewModel() : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, + nip94attachments = usedAttachments, ) } else { account?.sendPrivateMessage( @@ -348,6 +352,7 @@ open class NewPostViewModel() : ViewModel() { zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, + nip94attachments = usedAttachments, ) } } else { @@ -461,21 +466,12 @@ open class NewPostViewModel() : ViewModel() { onProgress = {}, ) - if (!isPrivate) { - createNIP94Record( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - ) - } else { - noNIP94( - uploadingResult = result, - localContentType = contentType, - alt = alt, - sensitiveContent = sensitiveContent, - ) - } + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) } catch (e: Exception) { if (e is CancellationException) throw e Log.e( @@ -508,6 +504,7 @@ open class NewPostViewModel() : ViewModel() { urlPreview = null isUploadingImage = false pTags = null + imetaTags.clear() wantsDirectMessage = false @@ -703,21 +700,6 @@ open class NewPostViewModel() : ViewModel() { contentToAddUrl == null } - fun includePollHashtagInMessage( - include: Boolean, - hashtag: String, - ) { - if (include) { - updateMessage(TextFieldValue(message.text + " $hashtag")) - } else { - updateMessage( - TextFieldValue( - message.text.replace(" $hashtag", "").replace(hashtag, ""), - ), - ) - } - } - suspend fun createNIP94Record( uploadingResult: Nip96Uploader.PartialEvent, localContentType: String?, @@ -751,25 +733,13 @@ open class NewPostViewModel() : ViewModel() { mimeType = remoteMimeType ?: localContentType, dimPrecomputed = dim, onReady = { header: FileHeader -> - account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { - event, - -> + account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> isUploadingImage = false nip94attachments = nip94attachments + event - val contentWarning = if (sensitiveContent) "" else null + message = TextFieldValue( - message.text + - "\n" + - addInlineMetadataAsNIP54( - imageUrl, - header.dim, - header.mimeType, - alt, - header.blurHash, - header.hash, - contentWarning, - ), + message.text + "\n" + imageUrl, ) urlPreview = findUrlInMessage() } @@ -781,84 +751,6 @@ 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?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }, - ) - .joinToString("&") - - return if (imageUrl.contains("#")) { - "$imageUrl&$extension" - } else { - "$imageUrl#$extension" - } - } - fun createNIP95Record( bytes: ByteArray, mimeType: String?, 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 8bd63e6f9..05586e242 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 @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.theme.markdownStyle import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -184,7 +185,7 @@ private fun RenderRegular( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val state by remember(content) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) } + val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) } val currentTextStyle = LocalTextStyle.current val currentTextColor = LocalContentColor.current @@ -416,8 +417,8 @@ private fun RenderContentAsMarkdown( onMediaCompose = { title, destination -> ZoomableContentView( content = - remember(destination) { - RichTextParser().parseMediaUrl(destination) ?: ZoomableUrlImage(url = destination) + remember(destination, tags) { + RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: ZoomableUrlImage(url = destination) }, roundedCorner = true, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 842f65f86..08dca663f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size15Modifier @@ -619,19 +620,18 @@ private fun RenderRegularTextNote( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - val modifier = remember { Modifier.padding(top = 5.dp) } - LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent -> if (eventContent != null) { SensitivityWarning( note = note, accountViewModel = accountViewModel, ) { + val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + TranslatableRichTextViewer( - content = eventContent!!, + content = eventContent, canPreview = canPreview, - modifier = modifier, + modifier = HalfTopPadding, tags = tags, backgroundColor = backgroundBubbleColor, accountViewModel = accountViewModel, @@ -642,8 +642,8 @@ private fun RenderRegularTextNote( TranslatableRichTextViewer( content = stringResource(id = R.string.could_not_decrypt_the_message), canPreview = true, - modifier = modifier, - tags = tags, + modifier = HalfTopPadding, + tags = EmptyTagList, backgroundColor = backgroundBubbleColor, accountViewModel = accountViewModel, nav = nav, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index a3b3d692d..82e2ef638 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -156,6 +156,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.quartz.events.Participant +import com.vitorpamplona.quartz.events.findURLs import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -300,6 +301,10 @@ fun ChannelScreen( dao = accountViewModel, ) tagger.run() + + val urls = findURLs(tagger.message) + val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + if (channel is PublicChatChannel) { accountViewModel.account.sendChannelMessage( message = tagger.message, @@ -307,6 +312,7 @@ fun ChannelScreen( replyTo = tagger.eTags, mentions = tagger.pTags, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } else if (channel is LiveActivitiesChannel) { accountViewModel.account.sendLiveMessage( @@ -315,6 +321,7 @@ fun ChannelScreen( replyTo = tagger.eTags, mentions = tagger.pTags, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } newPostModel.message = TextFieldValue("") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index d045b83f1..c205ff87c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -115,6 +115,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey +import com.vitorpamplona.quartz.events.findURLs import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers @@ -324,6 +325,9 @@ fun ChatroomScreen( // LAST ROW PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { scope.launch(Dispatchers.IO) { + val urls = findURLs(newPostModel.message.text) + val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { accountViewModel.account.sendNIP24PrivateMessage( message = newPostModel.message.text, @@ -331,6 +335,7 @@ fun ChatroomScreen( replyingTo = replyTo.value, mentions = null, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } else { accountViewModel.account.sendPrivateMessage( @@ -339,6 +344,7 @@ fun ChatroomScreen( replyingTo = replyTo.value, mentions = null, wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip29.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip29.kt new file mode 100644 index 000000000..6457ac843 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip29.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.encoders + +import com.vitorpamplona.quartz.events.FileHeaderEvent + +class Nip29 { + companion object { + private const val IMETA = "imeta" + } + + fun convertFromFileHeader(header: FileHeaderEvent): Array? { + val myUrl = header.url() ?: return null + return createTag( + myUrl, + header.tags, + ) + } + + fun createTag( + imageUrl: String, + tags: Array>, + ): Array { + return arrayOf( + IMETA, + "url $imageUrl", + ) + + tags.mapNotNull { + if (it.isNotEmpty() && it[0] != "url") { + if (it.size > 1) { + "${it[0]} ${it[1]}" + } else { + "${it[0]}}" + } + } else { + null + } + } + } + + fun parse( + imageUrl: String, + tags: Array>, + ): Map { + return tags.firstOrNull { + it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl" + }?.let { tagList -> + tagList.associate { tag -> + val parts = tag.split(" ", limit = 2) + when (parts.size) { + 2 -> parts[0] to parts[1] + 1 -> parts[0] to "" + else -> "" to "" + } + } + } ?: emptyMap() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54.kt similarity index 65% rename from app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54.kt index d203c9d8d..1994cf71a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip44UrlParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54.kt @@ -18,13 +18,47 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.quartz.encoders +import com.vitorpamplona.quartz.events.FileHeaderEvent import java.net.URI import java.net.URLDecoder +import java.net.URLEncoder import kotlin.coroutines.cancellation.CancellationException -class Nip44UrlParser { +class Nip54 { + fun convertFromFileHeader(header: FileHeaderEvent): String? { + val myUrl = header.url() ?: return null + return createUrl( + myUrl, + header.tags, + ) + } + + fun createUrl( + imageUrl: String, + tags: Array>, + ): String { + val extension = + tags.mapNotNull { + if (it.isNotEmpty() && it[0] != "url") { + if (it.size > 1) { + "${it[0]}=${URLEncoder.encode(it[1], "utf-8")}" + } else { + "${it[0]}}=" + } + } else { + null + } + }.joinToString("&") + + return if (imageUrl.contains("#")) { + "$imageUrl&$extension" + } else { + "$imageUrl#$extension" + } + } + fun parse(url: String): Map { return try { fragments(URI(url)) 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 265709839..ce9de89e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip29 import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -58,7 +59,7 @@ class ChannelMessageEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, onReady: (ChannelMessageEvent) -> Unit, ) { val tags = @@ -77,7 +78,9 @@ class ChannelMessageEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip29().convertFromFileHeader(it)?.let { + tags.add(it) + } } } tags.add( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index 77a62b9b6..e17753029 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip29 import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableSet @@ -80,6 +81,7 @@ class ChatMessageEvent( geohash: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + nip94attachments: List? = null, onReady: (ChatMessageEvent) -> Unit, ) { val tags = mutableListOf>() @@ -95,6 +97,13 @@ class ChatMessageEvent( zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } subject?.let { tags.add(arrayOf("subject", it)) } + nip94attachments?.let { + it.forEach { + Nip29().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + } // tags.add(arrayOf("alt", alt)) signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index 0c7a8fa0b..1d760fe14 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -62,17 +62,17 @@ class FileHeaderEvent( const val KIND = 1063 const val ALT_DESCRIPTION = "Verifiable file url" - private const val URL = "url" - private const val ENCRYPTION_KEY = "aes-256-gcm" - private const val MIME_TYPE = "m" - private const val FILE_SIZE = "size" - private const val DIMENSION = "dim" - private const val HASH = "x" - private const val MAGNET_URI = "magnet" - private const val TORRENT_INFOHASH = "i" - private const val BLUR_HASH = "blurhash" - private const val ORIGINAL_HASH = "ox" - private const val ALT = "alt" + const val URL = "url" + const val ENCRYPTION_KEY = "aes-256-gcm" + const val MIME_TYPE = "m" + const val FILE_SIZE = "size" + const val DIMENSION = "dim" + const val HASH = "x" + const val MAGNET_URI = "magnet" + const val TORRENT_INFOHASH = "i" + const val BLUR_HASH = "blurhash" + const val ORIGINAL_HASH = "ox" + const val ALT = "alt" fun create( url: String, 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 d2349f15a..efeb2441e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip29 import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -70,7 +71,7 @@ class LiveActivitiesChatMessageEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, onReady: (LiveActivitiesChatMessageEvent) -> Unit, ) { val content = message @@ -90,7 +91,9 @@ class LiveActivitiesChatMessageEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip29().convertFromFileHeader(it)?.let { + tags.add(it) + } } } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt index 4fddd1925..a99a3e2d6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt @@ -77,6 +77,7 @@ class NIP24Factory { markAsSensitive: Boolean = false, zapRaiserAmount: Long? = null, geohash: String? = null, + nip94attachments: List? = null, onReady: (Result) -> Unit, ) { val senderPublicKey = signer.pubKey @@ -92,6 +93,7 @@ class NIP24Factory { markAsSensitive = markAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, ) { senderMessage -> createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> onReady( 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 3d9a5dc57..13d90d82a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip29 import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -78,7 +79,7 @@ class PollNoteEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, onReady: (PollNoteEvent) -> Unit, ) { val tags = mutableListOf>() @@ -104,7 +105,9 @@ class PollNoteEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip29().convertFromFileHeader(it)?.let { + tags.add(it) + } } } tags.add(arrayOf("alt", ALT)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index d5475ca39..dd1d619cb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexValidator +import com.vitorpamplona.quartz.encoders.Nip54 import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentSetOf @@ -122,14 +123,24 @@ class PrivateDmEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, + nip94attachments: List? = null, onReady: (PrivateDmEvent) -> Unit, ) { - val message = + var message = msg + nip94attachments?.forEach { + val myUrl = it.url() + if (myUrl != null) { + message = message.replace(myUrl, Nip54().createUrl(myUrl, it.tags)) + } + } + + message = if (advertiseNip18) { - NIP_18_ADVERTISEMENT + NIP_18_ADVERTISEMENT + message } else { - "" - } + msg + message + } + val tags = mutableListOf>() publishedRecipientPubKey?.let { tags.add(arrayOf("p", publishedRecipientPubKey)) } replyTos?.forEach { tags.add(arrayOf("e", it)) } @@ -142,6 +153,15 @@ class PrivateDmEvent( } zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } geohash?.let { tags.addAll(geohashMipMap(it)) } + /* Privacy issue: DO NOT ADD THESE TO THE TAGS. + nip94attachments?.let { + it.forEach { + Nip29().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + }*/ + tags.add(arrayOf("alt", ALT)) signer.nip04Encrypt(message, recipientPubKey) { content -> 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 7abb7b242..ac97f781a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -25,6 +25,7 @@ import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip29 import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -55,7 +56,7 @@ class TextNoteEvent( root: String?, directMentions: Set, geohash: String? = null, - nip94attachments: List? = null, + nip94attachments: List? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (TextNoteEvent) -> Unit, @@ -106,7 +107,9 @@ class TextNoteEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } nip94attachments?.let { it.forEach { - // tags.add(arrayOf("nip94", it.toJson())) + Nip29().convertFromFileHeader(it)?.let { + tags.add(it) + } } }