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 7c530058f..499dd5ef4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelHideMessageEvent +import com.vitorpamplona.quartz.events.ChannelListEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMuteUserEvent @@ -57,6 +58,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.CommunityListEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent @@ -463,6 +465,20 @@ object LocalCache { consumeBaseReplaceable(event, relay) } + fun consume( + event: CommunityListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + + fun consume( + event: ChannelListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + fun consume( event: FileServersEvent, relay: Relay?, @@ -1772,6 +1788,7 @@ object LocalCache { is CalendarTimeSlotEvent -> consume(event, relay) is CalendarRSVPEvent -> consume(event, relay) is ChannelCreateEvent -> consume(event) + is ChannelListEvent -> consume(event, relay) is ChannelHideMessageEvent -> consume(event) is ChannelMessageEvent -> consume(event, relay) is ChannelMetadataEvent -> consume(event) @@ -1779,6 +1796,7 @@ object LocalCache { is ChatMessageEvent -> consume(event, relay) is ClassifiedsEvent -> consume(event, relay) is CommunityDefinitionEvent -> consume(event, relay) + is CommunityListEvent -> consume(event, relay) is CommunityPostApprovalEvent -> { event.containedPost()?.let { verifyAndConsume(it, relay) } consume(event) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelListEvent.kt new file mode 100644 index 000000000..49e7f1191 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelListEvent.kt @@ -0,0 +1,274 @@ +/** + * 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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableSet + +@Immutable +class ChannelListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateEventCache: ImmutableSet? = null + + override fun dTag() = FIXED_D_TAG + + fun publicAndPrivateEvents( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, + ) { + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + return + } + + privateTagsOrEmpty(signer) { + publicAndPrivateEventCache = filterTagList("e", it) + + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + } + } + } + + companion object { + const val KIND = 10005 + const val FIXED_D_TAG = "" + const val ALT = "Public Chat List" + + fun blockListFor(pubKeyHex: HexKey): String { + return "$KIND:$pubKeyHex:" + } + + fun createListWithTag( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithEvent( + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + return createListWithTag("e", eventId, isPrivate, signer, createdAt, onReady) + } + + fun addEvents( + earlierVersion: ChannelListEvent, + listEvents: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listEvents.map { arrayOf("e", it) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listEvents.map { arrayOf("e", it) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addEvent( + earlierVersion: ChannelListEvent, + event: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + return addTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: ChannelListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun removeEvent( + earlierVersion: ChannelListEvent, + event: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: ChannelListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChannelListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityListEvent.kt new file mode 100644 index 000000000..cb26353b5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommunityListEvent.kt @@ -0,0 +1,279 @@ +/** + * 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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet + +@Immutable +class CommunityListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateEventCache: ImmutableSet? = null + + override fun dTag() = FIXED_D_TAG + + fun publicAndPrivateEvents( + signer: NostrSigner, + onReady: (ImmutableSet) -> Unit, + ) { + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + return + } + + privateTagsOrEmpty(signer) { + publicAndPrivateEventCache = + filterTagList("a", it) + .mapNotNull { ATag.parseAtag(it, null) } + .toImmutableSet() + + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + } + } + } + + companion object { + const val KIND = 10004 + const val FIXED_D_TAG = "" + const val ALT = "Community List" + + fun blockListFor(pubKeyHex: HexKey): String { + return "$KIND:$pubKeyHex:" + } + + fun createListWithTag( + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + if (isPrivate) { + encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> + create( + content = encryptedTags, + tags = emptyArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } else { + create( + content = "", + tags = arrayOf(arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun createListWithEvent( + address: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + return createListWithTag("a", address.toTag(), isPrivate, signer, createdAt, onReady) + } + + fun addEvents( + earlierVersion: CommunityListEvent, + listAddresses: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags.plus( + listAddresses.map { arrayOf("a", it.toTag()) }, + ), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags.plus( + listAddresses.map { arrayOf("a", it.toTag()) }, + ), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + + fun addEvent( + earlierVersion: CommunityListEvent, + address: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + return addTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) + } + + fun addTag( + earlierVersion: CommunityListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (!isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = privateTags.plus(element = arrayOf(key, tag)), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun removeEvent( + earlierVersion: CommunityListEvent, + address: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + return removeTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) + } + + fun removeTag( + earlierVersion: CommunityListEvent, + key: String, + tag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> + if (isTagged) { + if (isPrivate) { + earlierVersion.privateTagsOrEmpty(signer) { privateTags -> + encryptTags( + privateTags = + privateTags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + ) { encryptedTags -> + create( + content = encryptedTags, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } else { + create( + content = earlierVersion.content, + tags = + earlierVersion.tags + .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } + .toTypedArray(), + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + } + } + } + } + + fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + arrayOf("alt", ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + } +} 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 6c29e297b..3e5e3b5dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -53,14 +53,14 @@ class EventFactory { ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) ChatMessageEvent.KIND -> { if (id.isBlank()) { - val newId = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() ChatMessageEvent( - newId, + Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey(), pubKey, createdAt, tags, @@ -74,6 +74,7 @@ class EventFactory { ClassifiedsEvent.KIND -> ClassifiedsEvent(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) ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)