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 c77e1235e..2d1d84a2d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GeneralListEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent +import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent @@ -1349,6 +1350,63 @@ class Account( } } + fun sendGitReply( + message: String, + replyTo: List?, + mentions: List?, + repository: ATag?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + replyingTo: String?, + root: String?, + directMentions: Set, + forkedFrom: Event?, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = listOfNotNull(repository) + (replyTo?.mapNotNull { it.address() } ?: emptyList()) + + GitReplyEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + extraTags = null, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + replyingTo = replyingTo, + root = root, + directMentions = directMentions, + geohash = geohash, + nip94attachments = nip94attachments, + forkedFrom = forkedFrom, + signer = signer, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // broadcast replied notes + replyingTo?.let { + LocalCache.getNoteIfExists(replyingTo)?.event?.let { + Client.send(it, relayList = relayList) + } + } + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + fun sendPost( message: String, replyTo: List?, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3953baa82..76ba4fade 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -451,14 +451,21 @@ object LocalCache { return } + val repository = event.repository()?.toTag() + val replyTo = event .tagsWithoutCitations() - .filter { it != event.repository()?.toTag() } + .filter { it != repository } .mapNotNull { checkGetOrCreateNote(it) } + // println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}") + note.loadEvent(event, author, replyTo) + // Counts the replies + replyTo.forEach { it.addReply(note) } + refreshObservers(note) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 618dcbeb9..659314695 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.OtsEvent @@ -138,6 +139,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { PollNoteEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, + GitReplyEvent.KIND, ), tags = mapOf("e" to it.map { it.idHex }), since = findMinimumEOSEs(it), 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 21439fb7f..0959d90b4 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 @@ -59,6 +59,7 @@ import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent @@ -434,6 +435,45 @@ open class NewPostViewModel() : ViewModel() { nip94attachments = usedAttachments, ) } + } else if (originalNote?.event is GitIssueEvent) { + val originalNoteEvent = originalNote?.event as GitIssueEvent + // adds markers + val rootId = + originalNoteEvent.rootIssueOrPatch() // if it has a marker as root + ?: originalNote + ?.replyTo + ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } + ?.idHex // if it has loaded events with zero replies in the reply list + ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. + ?: originalNote?.idHex + + val replyId = originalNote?.idHex + + val replyToSet = + if (forkedFromNote != null) { + (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } + } else { + tagger.eTags + } + + val repositoryAddress = originalNoteEvent.repository() + + account?.sendGitReply( + message = tagger.message, + replyTo = replyToSet, + mentions = tagger.pTags, + repository = repositoryAddress, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + replyingTo = replyId, + root = rootId, + directMentions = tagger.directMentions, + forkedFrom = forkedFromNote?.event as? Event, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + ) } else { if (wantsPoll) { account?.sendPoll( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt index ed11d2015..2c1752ffe 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitIssueEvent.kt @@ -41,7 +41,7 @@ class GitIssueEvent( private fun repositoryHex() = innerRepository()?.getOrNull(1) - fun rootIssueOrPath() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + fun rootIssueOrPatch() = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) fun repository() = innerRepository()?.let { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt index 505685b16..f083c895f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.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.Nip92MediaAttachments import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -75,5 +76,87 @@ class GitReplyEvent( signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) } + + fun create( + msg: String, + replyTos: List? = null, + mentions: List? = null, + addresses: List? = null, + extraTags: List? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + replyingTo: String? = null, + root: String? = null, + directMentions: Set = emptySet(), + geohash: String? = null, + nip94attachments: List? = null, + forkedFrom: Event? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GitReplyEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "e", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + forkedFrom = forkedFrom?.id, + ), + ) + } + mentions?.forEach { + if (it in directMentions) { + tags.add(arrayOf("p", it, "", "mention")) + } else { + tags.add(arrayOf("p", it)) + } + } + replyTos?.forEach { + if (it in directMentions) { + tags.add(arrayOf("q", it)) + } + } + addresses + ?.map { it.toTag() } + ?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "a", + root = root, + replyingTo = replyingTo, + directMentions = directMentions, + forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(), + ), + ) + } + findHashtags(msg).forEach { + tags.add(arrayOf("t", it)) + tags.add(arrayOf("t", it.lowercase())) + } + extraTags?.forEach { tags.add(arrayOf("t", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + findURLs(msg).forEach { tags.add(arrayOf("r", it)) } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + } + tags.add(arrayOf("alt", "a git issue reply")) + + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } } } 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 79a3161b2..13f81f043 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -123,45 +123,45 @@ class TextNoteEvent( signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) } - - /** - * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the - * deprecated method of positional tags to maximize backwards compatibility with clients that - * support replies but have not been updated to understand tag markers. - * - * https://github.com/nostr-protocol/nips/blob/master/10.md - * - * The tag to the root of the reply chain goes first. The tag to the reply event being responded - * to goes last. The order for any other tag does not matter, so keep the relative order. - */ - private fun List.positionalMarkedTags( - tagName: String, - root: String?, - replyingTo: String?, - directMentions: Set, - forkedFrom: String?, - ) = sortedWith { o1, o2 -> - when { - o1 == o2 -> 0 - o1 == root -> -1 // root goes first - o2 == root -> 1 // root goes first - o1 == replyingTo -> 1 // reply event being responded to goes last - o2 == replyingTo -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag - } - } - .map { - when (it) { - root -> arrayOf(tagName, it, "", "root") - replyingTo -> arrayOf(tagName, it, "", "reply") - forkedFrom -> arrayOf(tagName, it, "", "fork") - in directMentions -> arrayOf(tagName, it, "", "mention") - else -> arrayOf(tagName, it) - } - } } } fun findURLs(text: String): List { return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } } + +/** + * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the + * deprecated method of positional tags to maximize backwards compatibility with clients that + * support replies but have not been updated to understand tag markers. + * + * https://github.com/nostr-protocol/nips/blob/master/10.md + * + * The tag to the root of the reply chain goes first. The tag to the reply event being responded + * to goes last. The order for any other tag does not matter, so keep the relative order. + */ +fun List.positionalMarkedTags( + tagName: String, + root: String?, + replyingTo: String?, + directMentions: Set, + forkedFrom: String?, +) = sortedWith { o1, o2 -> + when { + o1 == o2 -> 0 + o1 == root -> -1 // root goes first + o2 == root -> 1 // root goes first + o1 == replyingTo -> 1 // reply event being responded to goes last + o2 == replyingTo -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } +} + .map { + when (it) { + root -> arrayOf(tagName, it, "", "root") + replyingTo -> arrayOf(tagName, it, "", "reply") + forkedFrom -> arrayOf(tagName, it, "", "fork") + in directMentions -> arrayOf(tagName, it, "", "mention") + else -> arrayOf(tagName, it) + } + }