diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a964b2a2c..57e890f41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -47,8 +47,11 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.ETag +import com.vitorpamplona.quartz.encoders.EventHint import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.Nip47WalletConnect +import com.vitorpamplona.quartz.encoders.PTag import com.vitorpamplona.quartz.encoders.RelayUrlFormatter import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent @@ -60,6 +63,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.Contact import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent @@ -2145,6 +2149,182 @@ class Account( } } + suspend fun sendReplyComment( + message: String, + replyingTo: Note, + directMentionsUsers: Set = emptySet(), + directMentionsNotes: Set = emptySet(), + nip94attachments: List? = null, + geohash: String? = null, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + relayList: List, + draftTag: String? = null, + ) { + if (!isWriteable()) return + + val usersMentioned = + directMentionsUsers + .mapTo(HashSet(directMentionsUsers.size)) { + PTag(it.pubkeyHex, it.latestMetadataRelay) + } + + val addressesMentioned = + directMentionsNotes + .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> + if (note is AddressableNote) { + note.address + } else { + null + } + } + + val eventsMentioned = + directMentionsNotes + .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> + if (note !is AddressableNote) { + ETag(note.idHex, note.author?.pubkeyHex, note.relayHintUrl()) + } else { + null + } + } + + CommentEvent.replyComment( + msg = message, + replyingTo = EventHint(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), + usersMentioned = usersMentioned, + addressesMentioned = addressesMentioned, + eventsMentioned = eventsMentioned, + nip94attachments = nip94attachments, + geohash = geohash, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + isDraft = draftTag != null, + signer = signer, + ) { + if (draftTag != null) { + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + replyingTo.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } + + suspend fun sendGeoComment( + message: String, + geohash: String, + replyingTo: Note? = null, + directMentionsUsers: Set = emptySet(), + directMentionsNotes: Set = emptySet(), + nip94attachments: List? = null, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + relayList: List, + draftTag: String? = null, + ) { + if (!isWriteable()) return + + val usersMentioned = + directMentionsUsers + .mapTo(HashSet(directMentionsUsers.size)) { + PTag(it.pubkeyHex, it.latestMetadataRelay) + } + + val addressesMentioned = + directMentionsNotes + .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> + if (note is AddressableNote) { + note.address + } else { + null + } + } + + val eventsMentioned = + directMentionsNotes + .mapNotNullTo(HashSet(directMentionsNotes.size)) { note -> + if (note !is AddressableNote) { + ETag(note.idHex, note.author?.pubkeyHex, note.relayHintUrl()) + } else { + null + } + } + + if (replyingTo != null) { + CommentEvent.replyComment( + msg = message, + replyingTo = EventHint(replyingTo.event as CommentEvent, replyingTo.relayHintUrl()), + usersMentioned = usersMentioned, + addressesMentioned = addressesMentioned, + eventsMentioned = eventsMentioned, + nip94attachments = nip94attachments, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + isDraft = draftTag != null, + signer = signer, + ) { + if (draftTag != null) { + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + replyingTo.event?.let { + Client.send(it, relayList = relayList) + } + } + } + } else { + CommentEvent.createGeoComment( + msg = message, + geohash = geohash, + usersMentioned = usersMentioned, + addressesMentioned = addressesMentioned, + eventsMentioned = eventsMentioned, + nip94attachments = nip94attachments, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + isDraft = draftTag != null, + signer = signer, + ) { + if (draftTag != null) { + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + } + } + } + } + suspend fun sendPost( message: String, replyTo: List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 0f94e7ffa..48fbf53ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityListEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent @@ -818,6 +819,8 @@ object LocalCache { is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) } is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is LnZapEvent -> event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + @@ -1632,6 +1635,35 @@ object LocalCache { refreshObservers(note) } + fun consume( + event: CommentEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + if (antiSpam.isSpam(event, relay)) { + return + } + + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + fun consume( event: LiveActivitiesChatMessageEvent, relay: Relay?, @@ -2698,6 +2730,7 @@ object LocalCache { is ChatMessageEvent -> consume(event, relay) is ChatMessageRelayListEvent -> consume(event, relay) is ClassifiedsEvent -> consume(event, relay) + is CommentEvent -> consume(event, relay) is CommunityDefinitionEvent -> consume(event, relay) is CommunityListEvent -> consume(event, relay) is CommunityPostApprovalEvent -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 135316dc5..79e58a47d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -97,7 +97,7 @@ class AddressableNote( return minOf(publishedAt, lastCreatedAt) } - fun dTag(): String? = (event as? AddressableEvent)?.dTag() + fun dTag(): String = address.dTag override fun wasOrShouldBeDeletedBy( deletionEvents: Set, @@ -157,13 +157,27 @@ open class Note( host.id, host.pubKey, host.kind, - relays.firstOrNull()?.url, + relayHintUrl(), ) } else { - Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl()) } } else { - Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url) + Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl()) + } + } + + fun relayHintUrl(): String? { + val authorRelay = author?.latestMetadataRelay + + return if (relays.isNotEmpty()) { + if (authorRelay != null && relays.any { it.url == authorRelay }) { + authorRelay + } else { + relays.firstOrNull()?.url + } + } else { + null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 76ef57633..c80b4331b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent @@ -237,6 +238,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { GitIssueEvent.KIND, GitPatchEvent.KIND, HighlightEvent.KIND, + CommentEvent.KIND, CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarRSVPEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt index 828d0acee..4e017b6eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.HighlightEvent -import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent @@ -57,12 +57,12 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") { ChannelMessageEvent.KIND, LongTextNoteEvent.KIND, PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, WikiNoteEvent.KIND, + CommentEvent.KIND, ), limit = 200, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index a8885a957..86284c410 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent @@ -66,6 +67,7 @@ object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") { AudioTrackEvent.KIND, AudioHeaderEvent.KIND, WikiNoteEvent.KIND, + CommentEvent.KIND, ), limit = 200, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index e3a9f050b..b1a5e5dfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent @@ -165,7 +166,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { HighlightEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, - PinListEvent.KIND, + CommentEvent.KIND, WikiNoteEvent.KIND, ), tags = @@ -204,8 +205,8 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { HighlightEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, - PinListEvent.KIND, WikiNoteEvent.KIND, + CommentEvent.KIND, ), tags = mapOf( @@ -244,6 +245,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { PinListEvent.KIND, WikiNoteEvent.KIND, CommunityPostApprovalEvent.KIND, + CommentEvent.KIND, ), tags = mapOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 71a54afae..fab8c6bc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.HighlightEvent @@ -177,6 +178,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe PollNoteEvent.KIND, NNSEvent.KIND, WikiNoteEvent.KIND, + CommentEvent.KIND, ), search = mySearchString, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 710563b55..3d09153ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -39,29 +39,23 @@ class NewMessageTagger( var dao: Dao, ) { val directMentions = mutableSetOf() + val directMentionsNotes = mutableSetOf() + val directMentionsUsers = mutableSetOf() fun addUserToMentions(user: User) { + directMentionsUsers.add(user) directMentions.add(user.pubkeyHex) pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user) } fun addNoteToReplyTos(note: Note) { + directMentionsNotes.add(note) directMentions.add(note.idHex) note.author?.let { addUserToMentions(it) } eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note) } - fun tagIndex(user: User): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0) - } - - fun tagIndex(note: Note): Int { - // Postr Events assembles replies before mentions in the tag order - return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0) - } - suspend fun run() { // adds all references to mentions and reply tos message.split('\n').forEach { paragraph: String -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index b65e9c13e..b4c118a5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event @@ -162,6 +163,7 @@ open class NewPostViewModel : ViewModel() { // GeoHash var wantsToAddGeoHash by mutableStateOf(false) var location: StateFlow? = null + var wantsExclusiveGeoPost by mutableStateOf(false) // ZapRaiser var canAddZapRaiser by mutableStateOf(false) @@ -336,7 +338,13 @@ open class NewPostViewModel : ViewModel() { wantsForwardZapTo = localfowardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" } - wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" } + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + if (geohash != null) { + wantsExclusiveGeoPost = draftEvent.kind() == CommentEvent.KIND + } + val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" } wantsZapraiser = zapraiser.isNotEmpty() zapRaiserAmount = null @@ -544,7 +552,37 @@ open class NewPostViewModel : ViewModel() { // 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) { + val replyingTo = originalNote + + if (replyingTo?.event is CommentEvent) { + account?.sendReplyComment( + message = tagger.message, + replyingTo = replyingTo, + directMentionsUsers = tagger.directMentionsUsers, + directMentionsNotes = tagger.directMentionsNotes, + nip94attachments = usedAttachments, + geohash = geoHash, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + relayList = relayList, + draftTag = localDraft, + ) + } else if (wantsExclusiveGeoPost && geoHash != null && (originalNote == null || originalNote?.event is CommentEvent)) { + account?.sendGeoComment( + message = tagger.message, + geohash = geoHash, + replyingTo = originalNote, + directMentionsUsers = tagger.directMentionsUsers, + directMentionsNotes = tagger.directMentionsNotes, + nip94attachments = usedAttachments, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + relayList = relayList, + draftTag = localDraft, + ) + } else if (originalNote?.channelHex() != null) { if (originalNote is AddressableEvent && originalNote?.address() != null) { account?.sendLiveMessage( message = tagger.message, @@ -946,6 +984,7 @@ open class NewPostViewModel : ViewModel() { wantsForwardZapTo = false wantsToMarkAsSensitive = false wantsToAddGeoHash = false + wantsExclusiveGeoPost = false forwardZapTo = Split() forwardZapToEditting = TextFieldValue("") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index d5e737ac2..2132975b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.ChannelMessageEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -75,6 +76,7 @@ class HomeConversationsFeedFilter( it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || + it.event is CommentEvent || it.event is LiveActivitiesChatMessageEvent ) && filterParams.match(it.event) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index f650584fe..3262e8e8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent @@ -98,6 +99,7 @@ class HomeNewThreadFeedFilter( (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || + noteEvent is CommentEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 7d27bfc5f..4d2335b93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -304,6 +304,7 @@ fun AppNavigation( val version = it.arguments?.getString("version") val draft = it.arguments?.getString("draft") val enableMessageInterface = it.arguments?.getBoolean("enableMessageInterface") ?: false + val enableGeolocation = it.arguments?.getBoolean("enableGeolocation") ?: false NewPostScreen( message = draftMessage, @@ -314,6 +315,7 @@ fun AppNavigation( version = version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draft = draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, enableMessageInterface = enableMessageInterface, + enableGeolocation = enableGeolocation, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 1a460faa0..cbc01fc9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -225,7 +225,7 @@ sealed class Route( object NewPost : Route( - route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}"e={quote}&fork={fork}&version={version}&draft={draft}&enableMessageInterface={enableMessageInterface}", + route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}"e={quote}&fork={fork}&version={version}&draft={draft}&enableGeolocation={enableGeolocation}&enableMessageInterface={enableMessageInterface}", icon = R.drawable.ic_moments, arguments = listOf( @@ -236,6 +236,7 @@ sealed class Route( navArgument("fork") { type = NavType.StringType }, navArgument("version") { type = NavType.StringType }, navArgument("draft") { type = NavType.StringType }, + navArgument("enableGeolocation") { type = NavType.BoolType }, navArgument("enableMessageInterface") { type = NavType.BoolType }, ).toImmutableList(), ) @@ -307,6 +308,7 @@ fun buildNewPostRoute( fork: String? = null, version: String? = null, draft: String? = null, + enableGeolocation: Boolean = false, enableMessageInterface: Boolean = false, ): String = "NewPost?" + @@ -317,4 +319,5 @@ fun buildNewPostRoute( "fork=${fork ?: ""}&" + "version=${version ?: ""}&" + "draft=${draft ?: ""}&" + + "enableGeolocation=$enableGeolocation&" + "enableMessageInterface=$enableMessageInterface" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 8b824a3ff..a4defbdfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -157,6 +157,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.DraftEvent @@ -683,6 +684,19 @@ private fun RenderNoteRow( nav, ) } + is CommentEvent -> { + RenderTextEvent( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + ) + } is NIP90ContentDiscoveryResponseEvent -> RenderNIP90ContentDiscoveryResponse( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 8af27c984..46d58420f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -164,6 +164,7 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -206,12 +207,14 @@ fun NewPostScreen( fork: Note? = null, version: Note? = null, draft: Note? = null, + enableGeolocation: Boolean = false, enableMessageInterface: Boolean = false, accountViewModel: AccountViewModel, nav: Nav, ) { val postViewModel: NewPostViewModel = viewModel() postViewModel.wantsDirectMessage = enableMessageInterface + postViewModel.wantsToAddGeoHash = enableGeolocation val context = LocalContext.current val activity = context.getActivity() @@ -693,27 +696,24 @@ fun TakePictureButton(onPictureTaken: (Uri) -> Unit) { }, ) - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { - if (cameraPermissionState.status.isGranted) { - scope.launch(Dispatchers.IO) { - imageUri = getPhotoUri(context) - imageUri?.let { uri -> launcher.launch(uri) } - } - } else { - cameraPermissionState.launchPermissionRequest() + IconButton( + onClick = { + if (cameraPermissionState.status.isGranted) { + scope.launch(Dispatchers.IO) { + imageUri = getPhotoUri(context) + imageUri?.let { uri -> launcher.launch(uri) } } - }, - ) { - Icon( - imageVector = Icons.Default.CameraAlt, - contentDescription = stringRes(id = R.string.upload_image), - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } + } else { + cameraPermissionState.launchPermissionRequest() + } + }, + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringRes(id = R.string.upload_image), + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) } } @@ -1332,6 +1332,13 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(vertical = 10.dp), ) + + SettingsRow( + R.string.geohash_exclusive, + R.string.geohash_exclusive_explainer, + ) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) + } } } else { LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 94af2c47b..dc1c2ef18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -50,6 +50,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -191,7 +192,11 @@ private fun HomePages( } }, floatingButton = { - NewNoteButton(accountViewModel, nav) + val list = + accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + NewNoteButton(nav, list.value == AROUND_ME) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt index afd99f15e..53eb9486c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt @@ -35,18 +35,17 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable fun NewNoteButton( - accountViewModel: AccountViewModel, nav: INav, + enableGeolocation: Boolean = false, ) { FloatingActionButton( onClick = { - val route = buildNewPostRoute() + val route = buildNewPostRoute(enableGeolocation = enableGeolocation) nav.nav(route) }, modifier = Size55Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index fb55ef542..ef45f6d63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -169,6 +169,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.DraftEvent @@ -572,6 +573,18 @@ private fun FullBleedNoteCompose( RenderDraft(baseNote, 3, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is CommentEvent) { + RenderTextEvent( + baseNote, + false, + canPreview, + quotesLeft = 3, + unPackReply = false, + backgroundColor, + editState, + accountViewModel, + nav, + ) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is TextNoteModificationEvent) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 09bd89729..88d66d974 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -645,6 +645,9 @@ Expose Location as Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location + Location-exclusive Post + Only followers of the location will see it. Your general followers won\'t see it. + Loading location No Location Permissions diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt index 7c777b04b..061a17c0d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ATag.kt @@ -24,14 +24,25 @@ import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class ATag( val kind: Int, val pubKeyHex: String, val dTag: String, - val relay: String?, ) { + var relay: String? = null + + constructor( + kind: Int, + pubKeyHex: String, + dTag: String, + relayHint: String?, + ) : this(kind, pubKeyHex, dTag) { + this.relay = relayHint + } + fun countMemory(): Long = 5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) 8L + // kind @@ -41,6 +52,10 @@ data class ATag( fun toTag() = assembleATag(kind, pubKeyHex, dTag) + fun toATagArray() = removeTrailingNullsAndEmptyOthers("a", toTag(), relay) + + fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", toTag(), relay) + fun toNAddr(): String = TlvBuilder() .apply { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ETag.kt new file mode 100644 index 000000000..dd280f4e3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/ETag.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 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 android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +data class ETag( + val eventId: HexKey, +) { + var relay: String? = null + var authorPubKeyHex: HexKey? = null + + constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.authorPubKeyHex = authorPubKeyHex + } + + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + eventId.bytesUsedInMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = Nip19Bech32.createNEvent(eventId, authorPubKeyHex, null, relay) + + fun toNote(): String = Nip19Bech32.createNote(eventId) + + fun toETagArray() = removeTrailingNullsAndEmptyOthers("e", eventId, relay, authorPubKeyHex) + + fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", eventId, relay, authorPubKeyHex) + + companion object { + fun parseNIP19(nevent: String): ETag? { + try { + val parsed = Nip19Bech32.uriToRoute(nevent)?.entity + + return when (parsed) { + is Nip19Bech32.Note -> ETag(parsed.hex) + is Nip19Bech32.NEvent -> ETag(parsed.hex, parsed.author, parsed.relay.firstOrNull()) + else -> null + } + } catch (e: Throwable) { + Log.w("PTag", "Issue trying to Decode NIP19 $this: ${e.message}") + return null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/EventHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/EventHint.kt new file mode 100644 index 000000000..2f185a621 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/EventHint.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024 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 androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +@Immutable +data class EventHint( + val event: T, +) { + var relay: String? = null + + constructor(event: T, relayHint: String? = null) : this(event) { + this.relay = relayHint + } + + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + event.countMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + fun toNEvent(): String = Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay) + + fun toNPub(): String = Nip19Bech32.createNPub(event.id) + + fun toTagArray(tag: String) = listOfNotNull(tag, event.id, relay, event.pubKey).toTypedArray() + + fun toETagArray() = toTagArray("e") + + fun toQTagArray() = toTagArray("q") +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt index 06dc00ed0..bdac4d327 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19Bech32.kt @@ -270,6 +270,10 @@ object Nip19Bech32 { }.build() .toNEvent() + fun createNote(eventId: HexKey): String = eventId.hexToByteArray().toNote() + + fun createNPub(authorPubKeyHex: HexKey): String = authorPubKeyHex.hexToByteArray().toNpub() + fun createNProfile( authorPubKeyHex: String, relay: List, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/PTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/PTag.kt new file mode 100644 index 000000000..c1304b5c9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/PTag.kt @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2024 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 android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +data class PTag( + val pubKeyHex: HexKey, +) { + var relay: String? = null + + constructor(pubKeyHex: HexKey, relayHint: String?) : this(pubKeyHex) { + this.relay = relayHint?.ifBlank { null } + } + + fun countMemory(): Long = + 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) + pubKeyHex.bytesUsedInMemory() + + (relay?.bytesUsedInMemory() ?: 0) + + fun toNProfile(): String = Nip19Bech32.createNProfile(pubKeyHex, relay?.let { listOf(it) } ?: emptyList()) + + fun toNPub(): String = Nip19Bech32.createNPub(pubKeyHex) + + fun toPTagArray() = removeTrailingNullsAndEmptyOthers("p", pubKeyHex, relay) + + companion object { + fun parseNAddr(nprofile: String): PTag? { + try { + val parsed = Nip19Bech32.uriToRoute(nprofile)?.entity + + return when (parsed) { + is Nip19Bech32.NPub -> PTag(parsed.hex) + is Nip19Bech32.NProfile -> PTag(parsed.hex, parsed.relay.firstOrNull()) + else -> null + } + } catch (e: Throwable) { + Log.w("PTag", "Issue trying to Decode NIP19 $this: ${e.message}") + return null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index e9c0c8f7e..ec09c454e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -71,7 +71,7 @@ open class BaseTextNoteEvent( } } - fun replyingTo(): HexKey? { + open fun replyingTo(): HexKey? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "e" }?.get(1) val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) @@ -79,7 +79,7 @@ open class BaseTextNoteEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - fun replyingToAddress(): ATag? { + open fun replyingToAddress(): ATag? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it[2]) } val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "reply" }?.let { ATag.parseAtag(it[1], it[2]) } val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" }?.let { ATag.parseAtag(it[1], it[2]) } @@ -87,7 +87,7 @@ open class BaseTextNoteEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - fun replyingToAddressOrEvent(): String? { + open fun replyingToAddressOrEvent(): String? { val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && (it[0] == "e" || it[0] == "a") }?.get(1) val newStyleReply = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "reply" }?.get(1) val newStyleRoot = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "root" }?.get(1) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt new file mode 100644 index 000000000..938ec4a2d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2024 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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.ETag +import com.vitorpamplona.quartz.encoders.EventHint +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments +import com.vitorpamplona.quartz.encoders.PTag +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers + +@Immutable +class CommentEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun root() = tags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) + + fun getRootScopes() = tags.filter { it.size > 1 && it[0] == "I" || it[0] == "A" || it[0] == "E" } + + fun getRootKinds() = tags.filter { it.size > 1 && it[0] == "K" } + + fun getDirectReplies() = tags.filter { it.size > 1 && it[0] == "i" || it[0] == "a" || it[0] == "e" } + + fun getDirectKinds() = tags.filter { it.size > 1 && it[0] == "k" } + + fun isGeohashTag(tag: Array) = tag.size > 1 && (tag[0] == "i" || tag[0] == "I") && tag[1].startsWith("geo:") + + private fun getGeoHashList() = tags.filter { isGeohashTag(it) } + + override fun hasGeohashes() = tags.any { isGeohashTag(it) } + + override fun geohashes() = getGeoHashList().map { it[1].drop(4).lowercase() } + + override fun getGeoHash(): String? = geohashes().maxByOrNull { it.length } + + override fun isTaggedGeoHash(hashtag: String) = tags.any { isGeohashTag(it) && it[1].endsWith(hashtag, true) } + + override fun isTaggedGeoHashes(hashtags: Set) = geohashes().any { it in hashtags } + + override fun replyTos(): List = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + tags.filter { it.size > 1 && it[0] == "E" }.map { it[1] } + + override fun replyingTo(): HexKey? = + tags.lastOrNull { it.size > 1 && it[0] == "e" }?.get(1) + ?: tags.lastOrNull { it.size > 1 && it[0] == "E" }?.get(1) + + override fun replyingToAddress(): ATag? = + tags.lastOrNull { it.size > 1 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it.getOrNull(2)) } + ?: tags.lastOrNull { it.size > 1 && it[0] == "A" }?.let { ATag.parseAtag(it[1], it.getOrNull(2)) } + + override fun replyingToAddressOrEvent(): HexKey? = replyingToAddress()?.toTag() ?: replyingTo() + + companion object { + const val KIND = 1111 + + fun rootGeohashMipMap(geohash: String): Array> = + geohash.indices + .asSequence() + .map { arrayOf("I", "geo:" + geohash.substring(0, it + 1)) } + .toList() + .reversed() + .toTypedArray() + + fun replyComment( + msg: String, + replyingTo: EventHint, + usersMentioned: Set = emptySet(), + addressesMentioned: Set = emptySet(), + eventsMentioned: Set = emptySet(), + nip94attachments: List? = null, + geohash: String? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + isDraft: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommentEvent) -> Unit, + ) { + val tags = mutableListOf>() + + tags.addAll(replyingTo.event.getRootScopes()) + tags.addAll(replyingTo.event.getRootKinds()) + + tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)) + tags.add(arrayOf("k", "${replyingTo.event.kind}")) + + create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady) + } + + fun createGeoComment( + msg: String, + geohash: String? = null, + usersMentioned: Set = emptySet(), + addressesMentioned: Set = emptySet(), + eventsMentioned: Set = emptySet(), + nip94attachments: List? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + isDraft: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommentEvent) -> Unit, + ) { + val tags = mutableListOf>() + geohash?.let { tags.addAll(rootGeohashMipMap(it)) } + tags.add(arrayOf("K", "geo")) + + create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, null, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady) + } + + private fun create( + msg: String, + tags: MutableList>, + usersMentioned: Set = emptySet(), + addressesMentioned: Set = emptySet(), + eventsMentioned: Set = emptySet(), + nip94attachments: List? = null, + geohash: String? = null, + zapReceiver: List? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + isDraft: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommentEvent) -> Unit, + ) { + usersMentioned.forEach { tags.add(it.toPTagArray()) } + addressesMentioned.forEach { tags.add(it.toQTagArray()) } + eventsMentioned.forEach { tags.add(it.toQTagArray()) } + + findHashtags(msg).forEach { + val lowercaseTag = it.lowercase() + tags.add(arrayOf("t", it)) + if (it != lowercaseTag) { + tags.add(arrayOf("t", it.lowercase())) + } + } + + findURLs(msg).forEach { tags.add(arrayOf("r", it)) } + + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + 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) + } + } + } + + if (isDraft) { + signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } else { + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index 360c21d17..cad29aeda 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -141,21 +141,11 @@ class ContactListEvent( val tags = listOf(arrayOf("alt", ALT)) + followUsers.map { - if (it.relayUri != null) { - arrayOf("p", it.pubKeyHex, it.relayUri) - } else { - arrayOf("p", it.pubKeyHex) - } + listOfNotNull("a", it.pubKeyHex, it.relayUri).toTypedArray() } + followTags.map { arrayOf("t", it) } + followEvents.map { arrayOf("e", it) } + - followCommunities.map { - if (it.relay != null) { - arrayOf("a", it.toTag(), it.relay) - } else { - arrayOf("a", it.toTag()) - } - } + + followCommunities.map { it.toATagArray() } + followGeohashes.map { arrayOf("g", it) } return signer.sign(createdAt, KIND, tags.toTypedArray(), content) @@ -189,13 +179,7 @@ class ContactListEvent( } + followTags.map { arrayOf("t", it) } + followEvents.map { arrayOf("e", it) } + - followCommunities.map { - if (it.relay != null) { - arrayOf("a", it.toTag(), it.relay) - } else { - arrayOf("a", it.toTag()) - } - } + + followCommunities.map { it.toATagArray() } + followGeohashes.map { arrayOf("g", it) } return create( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt index d0c139705..e17c91766 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt @@ -188,6 +188,18 @@ class DraftEvent( create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) } + fun create( + dTag: String, + originalNote: CommentEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tagsWithMarkers = originalNote.getRootScopes() + originalNote.getDirectReplies() + + create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + } + fun create( dTag: String, originalNote: TextNoteEvent, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 821fd396c..12e980a6e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -75,6 +75,7 @@ class EventFactory { } ChatMessageRelayListEvent.KIND -> ChatMessageRelayListEvent(id, pubKey, createdAt, tags, content, sig) ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) + CommentEvent.KIND -> CommentEvent(id, pubKey, createdAt, tags, content, sig) CommunityDefinitionEvent.KIND -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) CommunityListEvent.KIND -> CommunityListEvent(id, pubKey, createdAt, tags, content, sig) CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt new file mode 100644 index 000000000..9ef3d5a6e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ArrayUtils.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 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.utils + +public fun removeTrailingNullsAndEmptyOthers(vararg elements: String?): Array { + val lastNonNullIndex = elements.indexOfLast { it != null } + + if (lastNonNullIndex < 0) return Array(0) { "" } + + return Array(lastNonNullIndex + 1) { index -> + elements[index] ?: "" + } +}