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 b3296b653..aef02d6ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -89,6 +89,11 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.events.NIP90StatusEvent +import com.vitorpamplona.quartz.events.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.events.NNSEvent import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -384,6 +389,146 @@ object LocalCache { refreshObservers(note) } + fun consume( + event: NIP90ContentDiscoveryResponseEvent, + relay: Relay? = null, + ) { + 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 + + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + fun consume( + event: NIP90ContentDiscoveryRequestEvent, + relay: Relay? = null, + ) { + 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 + + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + fun consume( + event: NIP90StatusEvent, + relay: Relay? = null, + ) { + 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 + + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + fun consume( + event: NIP90UserDiscoveryResponseEvent, + relay: Relay? = null, + ) { + 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 + + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + + fun consume( + event: NIP90UserDiscoveryRequestEvent, + relay: Relay? = null, + ) { + 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 + + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} + // ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Counts the replies + replyTo.forEach { it.addReply(note) } + + refreshObservers(note) + } + fun consume( event: GitPatchEvent, relay: Relay? = null, @@ -2299,6 +2444,11 @@ object LocalCache { } } is LnZapRequestEvent -> consume(event) + is NIP90StatusEvent -> consume(event, relay) + is NIP90ContentDiscoveryResponseEvent -> consume(event, relay) + is NIP90ContentDiscoveryRequestEvent -> consume(event, relay) + is NIP90UserDiscoveryResponseEvent -> consume(event, relay) + is NIP90UserDiscoveryRequestEvent -> consume(event, relay) is LnZapPaymentRequestEvent -> consume(event) is LnZapPaymentResponseEvent -> consume(event) is LongTextNoteEvent -> consume(event, relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index ba9f22348..ab0d301a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter +import com.vitorpamplona.quartz.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent @@ -34,6 +35,8 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.events.NIP90StatusEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -131,6 +134,61 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { ) } + fun createNIP89Filter(kTags: List): List { + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + tags = mapOf("k" to kTags), + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + ) + } + + fun createNIP90ResponseFilter(): List { + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + ) + } + + fun createNIP90StatusFilter(): List { + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + JsonFilter( + kinds = listOf(NIP90StatusEvent.KIND), + limit = 300, + since = + latestEOSEs.users[account.userProfile()] + ?.followList + ?.get(account.defaultDiscoveryFollowList.value) + ?.relayList, + ), + ), + ) + } + fun createLiveStreamFilter(): List { val follows = account.liveDiscoveryFollowLists.value?.users?.toList() @@ -404,6 +462,9 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { override fun updateChannelFilters() { discoveryFeedChannel.typedFilters = createLiveStreamFilter() + .plus(createNIP89Filter(listOf("5300"))) + .plus(createNIP90ResponseFilter()) + .plus(createNIP90StatusFilter()) .plus(createPublicChatFilter()) .plus(createMarketplaceFilter()) .plus( @@ -417,6 +478,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { createPublicChatsGeohashesFilter(), ), ) + .toList() .ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt new file mode 100644 index 000000000..cf08abb1c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt @@ -0,0 +1,87 @@ +/** + * 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.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.AppDefinitionEvent +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +open class DiscoverNIP89FeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + val lastAnnounced = 90 * 24 * 60 * 60 // 90 Days ago + // TODO better than announced would be last active, as this requires the DVM provider to regularly update the NIP89 announcement + + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } + + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } + + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.addressables.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is AppDefinitionEvent && noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent) + } + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + fun buildFilterParams(account: Account): FilterByListParams { + return FilterByListParams.create( + account.userProfile().pubkeyHex, + account.defaultDiscoveryFollowList.value, + account.liveDiscoveryFollowLists.value, + account.flowHiddenUsers.value, + ) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is AppDefinitionEvent && noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent) + } + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryFilter.kt new file mode 100644 index 000000000..0fc4cc2a9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryFilter.kt @@ -0,0 +1,132 @@ +/** + * 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.amethyst.ui.dal + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.events.PeopleListEvent + +open class NIP90ContentDiscoveryFilter( + val account: Account, + val dvmkey: String, + val request: String, +) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + request + } + + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } + + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is NIP90ContentDiscoveryResponseEvent && it.event!!.isTaggedEvent(request) + // it.event?.pubKey() == dvmkey && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) + } + + var sorted = sort(notes) + if (sorted.isNotEmpty()) { + var note = sorted.first() + + var eventContent = note.event?.content() + + var collection: MutableSet = mutableSetOf() + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + var etags = mapper.readValue(eventContent, List::class.java) + for (element in etags) { + var tag = mapper.readValue(mapper.writeValueAsString(element), Array::class.java) + val note = LocalCache.checkGetOrCreateNote(tag[1].toString()) + if (note != null) { + collection.add(note) + } + } + + return collection.toList() + } else { + return listOf() + } + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + fun buildFilterParams(account: Account): FilterByListParams { + return FilterByListParams.create( + account.userProfile().pubkeyHex, + account.defaultDiscoveryFollowList.value, + account.liveDiscoveryFollowLists.value, + account.flowHiddenUsers.value, + ) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + // val params = buildFilterParams(account) + + val notes = + collection.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is NIP90ContentDiscoveryResponseEvent && // && + it.event!!.isTaggedEvent(request) // && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) + } + + val sorted = sort(notes) + if (sorted.isNotEmpty()) { + var note = sorted.first() + + var eventContent = note.event?.content() + // println(eventContent) + + val collection: MutableSet = mutableSetOf() + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + var etags = mapper.readValue(eventContent, Array::class.java) + for (element in etags) { + var tag = mapper.readValue(mapper.writeValueAsString(element), Array::class.java) + val note = LocalCache.checkGetOrCreateNote(tag[1].toString()) + if (note != null) { + collection.add(note) + } + } + return collection + } else { + return hashSetOf() + } + } + + override fun sort(collection: Set): List { + return collection.toList() // collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90StatusFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90StatusFilter.kt new file mode 100644 index 000000000..6cf787bfe --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90StatusFilter.kt @@ -0,0 +1,98 @@ +/** + * 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.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.NIP90StatusEvent +import com.vitorpamplona.quartz.events.PeopleListEvent + +open class NIP90StatusFilter( + val account: Account, + val dvmkey: String, + val request: String, +) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } + + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } + + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } + + override fun feed(): List { + val params = buildFilterParams(account) + + val status = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is NIP90StatusEvent && it.event?.pubKey() == dvmkey && + it.event!!.isTaggedEvent(request) + // && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) + } + if (status.isNotEmpty()) { + return listOf(status.first()) + } else { + return listOf() + } + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + fun buildFilterParams(account: Account): FilterByListParams { + return FilterByListParams.create( + account.userProfile().pubkeyHex, + account.defaultDiscoveryFollowList.value, + account.liveDiscoveryFollowLists.value, + account.flowHiddenUsers.value, + ) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + // val params = buildFilterParams(account) + + val status = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is NIP90StatusEvent && it.event?.pubKey() == dvmkey && + it.event!!.isTaggedEvent(request) + // && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) + } + if (status.isNotEmpty()) { + return setOf(status.first()) + } else { + return setOf() + } + } + + override fun sort(collection: Set): List { + return collection.toList() // collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index e97dd069d..613d806f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -37,6 +37,9 @@ import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.events.NIP90StatusEvent import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent @@ -111,6 +114,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() it.event !is LnZapRequestEvent && it.event !is BadgeDefinitionEvent && it.event !is BadgeProfilesEvent && + it.event !is NIP90ContentDiscoveryResponseEvent && it.event !is NIP90StatusEvent && it.event !is NIP90ContentDiscoveryRequestEvent && it.event !is GiftWrapEvent && (it.event is LnZapEvent || notifAuthor != loggedInUserHex) && (filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index bd13a00b0..51250e69d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverNIP89FeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.NIP90ContentDiscoveryScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen @@ -86,6 +88,7 @@ fun AppNavigation( newFeedViewModel: NostrChatroomListNewFeedViewModel, videoFeedViewModel: NostrVideoFeedViewModel, discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, + discoverNip89FeedViewModel: NostrDiscoverNIP89FeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, @@ -173,6 +176,7 @@ fun AppNavigation( route.arguments, content = { DiscoverScreen( + discoveryContentNIP89FeedViewModel = discoverNip89FeedViewModel, discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, @@ -215,8 +219,25 @@ fun AppNavigation( composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) }) composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) }) + composable(Route.Drafts.route, content = { DraftListScreen(accountViewModel, nav) }) + Route.ContentDiscovery.let { route -> + composable( + route.route, + route.arguments, + content = { + it.arguments?.getString("id")?.let { it1 -> + NIP90ContentDiscoveryScreen( + DVMID = it1, + accountViewModel = accountViewModel, + nav = nav, + ) + } + }, + ) + } + Route.Profile.let { route -> composable( route.route, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 32974b149..268d043f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -188,6 +188,8 @@ private fun RenderTopRouteBar( Route.Settings.base -> TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack) Route.Bookmarks.base -> TopBarWithBackButton(stringResource(id = R.string.bookmarks), navPopBack) Route.Drafts.base -> TopBarWithBackButton(stringResource(id = R.string.drafts), navPopBack) + Route.ContentDiscovery.base -> TopBarWithBackButton(stringResource(id = R.string.discover_content), navPopBack) + else -> { if (id != null) { when (currentRoute) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 0d5ccb18e..b9d0089c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -148,6 +148,14 @@ sealed class Route( contentDescriptor = R.string.route_home, ) + object ContentDiscovery : + Route( + icon = R.drawable.ic_bookmarks, + contentDescriptor = R.string.discover_content, + route = "ContentDiscovery/{id}", + arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), + ) + object Drafts : Route( route = "Drafts", diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 85f8ab9ca..533f3e93b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent @@ -213,6 +214,9 @@ fun InnerChannelCardWithReactions( is ClassifiedsEvent -> { InnerCardBox(baseNote, accountViewModel, nav) } + is AppDefinitionEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } } } @@ -268,6 +272,9 @@ private fun RenderNoteRow( is ChannelCreateEvent -> { RenderChannelThumb(baseNote, accountViewModel, nav) } + is AppDefinitionEvent -> { + RenderContentDVMThumb(baseNote, accountViewModel, nav) + } } } @@ -516,6 +523,13 @@ data class CommunityCard( val moderators: ImmutableList, ) +@Immutable +data class DVMCard( + val name: String, + val description: String?, + val cover: String?, +) + @Composable fun RenderCommunitiesThumb( baseNote: Note, @@ -715,6 +729,92 @@ private fun LoadParticipants( inner(participantUsers) } +@Composable +fun RenderContentDVMThumb( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? AppDefinitionEvent ?: return + + val card by + baseNote + .live() + .metadata + .map { + val noteEvent = it.note.event as? AppDefinitionEvent + + DVMCard( + name = noteEvent?.appMetaData()?.name ?: "", + description = noteEvent?.appMetaData()?.about ?: "", + cover = noteEvent?.appMetaData()?.image?.ifBlank { null }, + ) + } + .distinctUntilChanged() + .observeAsState( + DVMCard( + name = noteEvent.appMetaData()?.name ?: "", + description = noteEvent.appMetaData()?.about ?: "", + cover = noteEvent.appMetaData()?.image?.ifBlank { null }, + ), + ) + + LeftPictureLayout( + onImage = { + card.cover?.let { + Box(contentAlignment = BottomStart) { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(QuoteBorder), + ) + } + } ?: run { DisplayAuthorBanner(baseNote) } + }, + onTitleRow = { + Text( + text = card.name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + onDescription = { + card.description?.let { + Spacer(modifier = StdVertSpacer) + Row { + Text( + text = it, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + } + } + }, + onBottomRow = { + }, + ) +} + @Composable fun RenderChannelThumb( baseNote: Note, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 313d327c0..472a69946 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -99,6 +99,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent +import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse +import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -161,6 +163,8 @@ import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.events.NIP90StatusEvent import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent @@ -420,13 +424,18 @@ fun ClickableNote( .combinedClickable( onClick = { scope.launch { - val redirectToNote = - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull() ?: baseNote - } else { - baseNote - } - routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav(it) } + if (baseNote.event is AppDefinitionEvent) { + // nav(Route.ContentDiscovery.route + "/${(baseNote.event as AppDefinitionEvent).pubKey()}") + nav("ContentDiscovery/${(baseNote.event as AppDefinitionEvent).pubKey()}") + } else { + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } + routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav(it) } + } } }, onLongClick = showPopup, @@ -663,6 +672,32 @@ private fun RenderNoteRow( nav, ) } + is NIP90ContentDiscoveryResponseEvent -> + RenderNIP90ContentDiscoveryResponse( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + ) + + is NIP90StatusEvent -> + RenderNIP90Status( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + ) + is PollNoteEvent -> { RenderPoll( baseNote, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 663bec70d..49d813789 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -70,8 +70,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.AppDefinitionEvent +import com.vitorpamplona.quartz.events.AppMetadata import com.vitorpamplona.quartz.events.EmptyTagList -import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -85,7 +85,7 @@ fun RenderAppDefinition( ) { val noteEvent = note.event as? AppDefinitionEvent ?: return - var metadata by remember { mutableStateOf(null) } + var metadata by remember { mutableStateOf(null) } LaunchedEffect(key1 = noteEvent) { withContext(Dispatchers.Default) { metadata = noteEvent.appMetaData() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt new file mode 100644 index 000000000..11cd5087b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -0,0 +1,154 @@ +/** + * 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.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.events.BaseTextNoteEvent +import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderNIP90ContentDiscoveryResponse( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + unPackReply: Boolean, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event + val modifier = remember(note) { Modifier.fillMaxWidth() } + + val showReply by + remember(note) { + derivedStateOf { + noteEvent is BaseTextNoteEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + } + } + + if (showReply) { + val replyingDirectlyTo = + remember(note) { + if (noteEvent is BaseTextNoteEvent) { + val replyingTo = noteEvent.replyingToAddressOrEvent() + if (replyingTo != null) { + val newNote = accountViewModel.getNoteIfExists(replyingTo) + if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) { + newNote + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } + if (replyingDirectlyTo != null && unPackReply) { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + } + + LoadDecryptedContent( + note, + accountViewModel, + ) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + val newBody = + if (editState.value is GenericLoadable.Loaded) { + val state = + (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow + state?.value?.event?.content() ?: body + } else { + body + } + + if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) { + "### $subject\n$newBody" + } else { + newBody + } + } + } + + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt new file mode 100644 index 000000000..0c3662332 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt @@ -0,0 +1,111 @@ +/** + * 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.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.BaseTextNoteEvent +import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.TextNoteEvent + +@Composable +fun RenderNIP90Status( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + unPackReply: Boolean, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event + val modifier = remember(note) { Modifier.fillMaxWidth() } + + val showReply by + remember(note) { + derivedStateOf { + noteEvent is BaseTextNoteEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + } + } + + if (showReply) { + val replyingDirectlyTo = + remember(note) { + if (noteEvent is BaseTextNoteEvent) { + val replyingTo = noteEvent.replyingToAddressOrEvent() + if (replyingTo != null) { + val newNote = accountViewModel.getNoteIfExists(replyingTo) + if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) { + newNote + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND } + } + } + } + + LoadDecryptedContent( + note, + accountViewModel, + ) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + val newBody = + if (editState.value is GenericLoadable.Loaded) { + val state = + (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow + state?.value?.event?.content() ?: body + } else { + body + } + + if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) { + "### $subject\n$newBody" + } else { + newBody + } + } + } + + Text(text = eventContent) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index a71951120..188fd589c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -77,6 +77,21 @@ fun RefresheableFeedView( } } +@Composable +fun DVMStatusView( + viewModel: FeedViewModel, + routeForLastRead: String?, + enablePullRefresh: Boolean = true, + scrollStateKey: String? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + viewModel.invalidateData() + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead) + } +} + @Composable fun RefresheableBox( viewModel: InvalidatableViewModel, @@ -286,3 +301,19 @@ fun FeedEmpty(onRefresh: () -> Unit) { OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } } } + +@Composable +fun FeedEmptywithStatus( + status: String, + onRefresh: () -> Unit, +) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(status) + // Spacer(modifier = StdVertSpacer) + // OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 7378ecf59..891ec905a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -47,12 +47,15 @@ import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DiscoverNIP89FeedFilter import com.vitorpamplona.amethyst.ui.dal.DraftEventsFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter +import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryFilter +import com.vitorpamplona.amethyst.ui.dal.NIP90StatusFilter import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter @@ -109,6 +112,17 @@ class NostrDiscoverMarketplaceFeedViewModel(val account: Account) : } } +class NostrDiscoverNIP89FeedViewModel(val account: Account) : + FeedViewModel( + DiscoverNIP89FeedFilter(account), + ) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverNIP89FeedViewModel { + return NostrDiscoverNIP89FeedViewModel(account) as NostrDiscoverNIP89FeedViewModel + } + } +} + class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) { class Factory(val account: Account) : ViewModelProvider.Factory { @@ -269,6 +283,26 @@ class NostrBookmarkPrivateFeedViewModel(val account: Account) : } } +@Stable +class NostrNIP90ContentDiscoveryFeedViewModel(val account: Account, val dvmkey: String, val requestid: String) : + FeedViewModel(NIP90ContentDiscoveryFilter(account, dvmkey, requestid)) { + class Factory(val account: Account, val dvmkey: String, val requestid: String) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrNIP90ContentDiscoveryFeedViewModel { + return NostrNIP90ContentDiscoveryFeedViewModel(account, dvmkey, requestid) as NostrNIP90ContentDiscoveryFeedViewModel + } + } +} + +@Stable +class NostrNIP90StatusFeedViewModel(val account: Account, val dvmkey: String, val requestid: String) : + FeedViewModel(NIP90StatusFilter(account, dvmkey, requestid)) { + class Factory(val account: Account, val dvmkey: String, val requestid: String) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrNIP90StatusFeedViewModel { + return NostrNIP90StatusFeedViewModel(account, dvmkey, requestid) as NostrNIP90StatusFeedViewModel + } + } +} + @Stable class NostrDraftEventsFeedViewModel(val account: Account) : FeedViewModel(DraftEventsFeedFilter(account)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index b2d928fcc..d4c0f453e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -43,6 +43,7 @@ object ScrollStateKeys { val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" + val DISCOVER_CONTENT = Route.Home.base + "DiscoverContent" val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" val DISCOVER_LIVE = Route.Home.base + "Live" val DISCOVER_COMMUNITY = Route.Home.base + "Communities" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index e6bf7d8d2..df2072c7b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverNIP89FeedViewModel import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys import com.vitorpamplona.amethyst.ui.screen.RefresheableBox import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState @@ -78,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent @@ -89,6 +91,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun DiscoverScreen( + discoveryContentNIP89FeedViewModel: NostrDiscoverNIP89FeedViewModel, discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, @@ -100,12 +103,20 @@ fun DiscoverScreen( val tabs by remember( + discoveryContentNIP89FeedViewModel, discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel, ) { mutableStateOf( listOf( + TabItem( + R.string.discover_content, + discoveryContentNIP89FeedViewModel, + Route.Discover.base + "DiscoverContent", + ScrollStateKeys.DISCOVER_CONTENT, + AppDefinitionEvent.KIND, + ), TabItem( R.string.discover_marketplace, discoveryMarketplaceFeedViewModel, @@ -142,6 +153,7 @@ fun DiscoverScreen( val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } WatchAccountForDiscoveryScreen( + discoverNIP89FeedViewModel = discoveryContentNIP89FeedViewModel, discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, @@ -310,6 +322,7 @@ private fun RenderDiscoverFeed( @Composable fun WatchAccountForDiscoveryScreen( + discoverNIP89FeedViewModel: NostrDiscoverNIP89FeedViewModel, discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, @@ -320,6 +333,7 @@ fun WatchAccountForDiscoveryScreen( LaunchedEffect(accountViewModel, listState) { NostrDiscoveryDataSource.resetFilters() + discoverNIP89FeedViewModel.checkKeysInvalidateDataAndSendToTop() discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() @@ -344,20 +358,30 @@ private fun DiscoverFeedLoaded( itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } - Row(defaultModifier) { - ChannelCardCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - modifier = Modifier.fillMaxWidth(), - forceEventKind = forceEventKind, - accountViewModel = accountViewModel, - nav = nav, + // TODO For now we avoid subscription based DVMs, as we need logic for these first if a user is not subscribed already. + var avoid = false + if (item.event is AppDefinitionEvent) { + if ((item.event as AppDefinitionEvent).appMetaData()?.subscription == true) { + avoid = true + } + } + // TODO End + if (!avoid) { + Row(defaultModifier) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + modifier = Modifier.fillMaxWidth(), + forceEventKind = forceEventKind, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, ) } - - HorizontalDivider( - thickness = DividerThickness, - ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index c04eb1a56..73f4d923d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverNIP89FeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -207,6 +208,12 @@ fun MainScreen( factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account), ) + val discoverNIP89FeedViewModel: NostrDiscoverNIP89FeedViewModel = + viewModel( + key = "NostrDiscoveryNIP89FeedViewModel", + factory = NostrDiscoverNIP89FeedViewModel.Factory(accountViewModel.account), + ) + val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = viewModel( key = "NostrDiscoveryLiveFeedViewModel", @@ -411,6 +418,7 @@ fun MainScreen( knownFeedViewModel = knownFeedViewModel, newFeedViewModel = newFeedViewModel, videoFeedViewModel = videoFeedViewModel, + discoverNip89FeedViewModel = discoverNIP89FeedViewModel, discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NIP90ContentDiscoveryScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NIP90ContentDiscoveryScreen.kt new file mode 100644 index 000000000..5c0121309 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NIP90ContentDiscoveryScreen.kt @@ -0,0 +1,156 @@ +/** + * 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.amethyst.ui.screen.loggedIn + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.amethyst.ui.screen.FeedEmptywithStatus +import com.vitorpamplona.amethyst.ui.screen.NostrNIP90ContentDiscoveryFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrNIP90StatusFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableBox +import com.vitorpamplona.amethyst.ui.screen.RenderFeedState +import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent + +@Composable +fun NIP90ContentDiscoveryScreen( + DVMID: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var requestID = "" + val thread = + Thread { + try { + NIP90ContentDiscoveryRequestEvent.create(DVMID, accountViewModel.account.signer) { + Client.send(it) + requestID = it.id + LocalCache.justConsume(it, null) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + thread.start() + thread.join() + + val resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel = + viewModel( + key = "NostrNIP90ContentDiscoveryFeedViewModel", + factory = NostrNIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmkey = DVMID, requestid = requestID), + ) + + val statusFeedViewModel: NostrNIP90StatusFeedViewModel = + viewModel( + key = "NostrNIP90StatusFeedViewModel", + factory = NostrNIP90StatusFeedViewModel.Factory(accountViewModel.account, dvmkey = DVMID, requestid = requestID), + ) + + val userState by accountViewModel.account.decryptBookmarks.observeAsState() // TODO + + LaunchedEffect(userState) { + resultFeedViewModel.invalidateData() + } + + RenderNostrNIP90ContentDiscoveryScreen(DVMID, accountViewModel, nav, resultFeedViewModel, statusFeedViewModel) +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun RenderNostrNIP90ContentDiscoveryScreen( + dvmID: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel, + statusFeedViewModel: NostrNIP90StatusFeedViewModel, +) { + Column(Modifier.fillMaxHeight()) { + val pagerState = rememberPagerState { 2 } + val coroutineScope = rememberCoroutineScope() + + // TODO (Optional) this now shows the first status update but there might be a better way + var dvmState = stringResource(R.string.dvm_waiting_status) + var dvmNoState = stringResource(R.string.dvm_no_status) + + val thread = + Thread { + var count = 0 + while (resultFeedViewModel.localFilter.feed().isEmpty()) { + try { + if (statusFeedViewModel.localFilter.feed().isNotEmpty()) { + statusFeedViewModel.localFilter.feed()[0].event?.let { dvmState = it.content() } + println(dvmState) + break + } else if (count > 1000) { + dvmState = dvmNoState + // Might not be the best way, but we want to avoid hanging in the loop forever + } else { + count++ + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + thread.start() + thread.join() + + // TODO (Optional) Maybe render a nice header with image and DVM name from the dvmID + // TODO (Optional) How do we get the event information here?, LocalCache.checkGetOrCreateNote() returns note but event is empty + // TODO (Optional) otherwise we have the NIP89 info in (note.event as AppDefinitionEvent).appMetaData() + // Text(text = dvminfo) + + HorizontalPager(state = pagerState) { + RefresheableBox(resultFeedViewModel, false) { + SaveableFeedState(resultFeedViewModel, null) { listState -> + // TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation + RenderFeedState( + resultFeedViewModel, + accountViewModel, + listState, + nav, + null, + onEmpty = { + // TODO (Optional) Maybe also show some dvm image/text while waiting for the notes in this custom component + FeedEmptywithStatus(status = dvmState) { + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 426131ee0..271ad4314 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -54,6 +54,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource @@ -91,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.findHashtags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -295,6 +298,15 @@ private fun SearchTextField( searchBarViewModel: SearchBarViewModel, onTextChanges: (String) -> Unit, ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + launch { + delay(100) + focusRequester.requestFocus() + } + } + Row( modifier = Modifier.padding(10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -312,7 +324,11 @@ private fun SearchTextField( capitalization = KeyboardCapitalization.Sentences, ), leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, - modifier = Modifier.weight(1f, true).defaultMinSize(minHeight = 20.dp), + modifier = + Modifier + .weight(1f, true) + .defaultMinSize(minHeight = 20.dp) + .focusRequester(focusRequester), placeholder = { Text( text = stringResource(R.string.npub_hex_username), diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index dc2434451..7b61dc44b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -429,6 +429,7 @@ Se déconnecter supprime toutes vos informations locales. Assurez-vous d\'avoir vos clés privées sauvegardées pour éviter de perdre votre compte. Voulez-vous continuer ? Tags suivis Relais + Découverte de notes Place de Marché Direct Communauté @@ -669,6 +670,10 @@ Afficher npub en tant que QR code Adresse invalide Amethyst a reçu une URI à ouvrir mais cette URI était invalide : %1$s + Configurer vos relais de messagerie privée + Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages. + Les relais de messagerie DM acceptent n\'importe quel message de n\'importe qui, mais vous permet seulement de les télécharger. Par exemple, inbox.nostr.wine fonctionne de cette façon. + Configurer maintenant Zap les Devs ! Votre don nous aide à faire la différence. Chaque sat compte ! Faire un don maintenant diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 6b02d4f8a..c64d825b1 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -19,32 +19,80 @@ Ikona retransmitera Autor nieznany Skopiuj tekst + Kopiuj identyfikator autora + Kopiuj identyfikator notatki Zablokuj / Zgłoś Zgłoś spam/oszustwo Zgłoś podszywanie się Zgłoś niedozwoloną zawartość + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc odpowiedzieć + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby polubić posty + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc wysyłać zapy + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc obserwować + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc przestać obserwować + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc ukryć słowo lub zdanie + Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc pokazać słowo lub zdanie + Zapy + Liczba wyświetleń + Zwiększ + zwiększony + edytowano + edytuj #%1$s + oryginalny + Nowa kwota w Satsach Dodaj + " i " + "na kanale " + Baner profilu Płatność zakończona pomyślnie + Błąd podczas analizowania komunikatu o błędzie + " Obserwowani" + " Obserwujący" + Profil + Filtry bezpieczeństwa Wyloguj się + Pokaż Więcej Zapłać + Notatka dla odbiorcy Dziękuję bardzo! + Kwota w Satsach + Wyślij Satsy + "Błąd analizowania podglądu dla %1$s : %2$s" + "Podgląd obrazu karty dla %1$s" Nowy kanał Nazwa kanału Moja wspaniała Grupa Adres URL zdjęcia + Opis "O nas. " Co masz na myśli? + Wpis Zapisz Utwórz Anuluj Adres retransmitera + Wpisy + Błędy Dodaj Retransmiter Nazwa użytkownika + O mnie + URL awatara + URL banera Adres URL strony Dodaj zdjęcie + Zablokowani użytkownicy + Notatki + Odpowiedzi + "Zgłoszenia" Więcej opcji " Retransmitery" + Strona www + Wyślij bezpośrednią wiadomość + Edytowanie metadanych użytkowników + Obserwuj + Również obserwuj Odblokuj Kopiuj ID użytkownika Odblokuj użytkownika @@ -52,13 +100,16 @@ Wyczyść Logo aplikacji nsec. lub npub. + hasło, aby otworzyć klucz Pokaż hasło Ukryj hasło Nieprawidłowy klucz + Nieprawidłowy klucz: %1$s "Akceptuję " warunki użytkowania Wymagane jest zaakceptowanie warunków użytkowania Hasło jest wymagane + Klucz jest wymagany Zaloguj się Zarejestruj się Utwórz konto @@ -75,18 +126,65 @@ Przestań obserwować Czat Publiczny Usuń + do + Adres Nostr + nigdy + teraz + godz. + Nagość + Wulgaryzmy / Mowa nienawiści + Zgłoś nienawistną mowę + Zgłoś Nagość / Pornografię + inne + Oznacz wszystkie jako przeczytane + Błąd Zaznacz tekst Dodaj nowe konto Konta Wybierz Konto Dodaj konto + Tylko do odczytu, brak klucza prywatnego Wstecz Wybierz + Udostępnij + ID autora + ID notatki Skopiuj tekst Usuń Przestań obserwować Śledź + Poproś o usunięcie + Amethyst poprosi o usunięcie Twojej notatki z aktualnie podłączonych retransmitorów. Nie ma gwarancji, że Twoja notatka zostanie trwale usunięta z tych retransmitorów lub z innych retransmitorów, gdzie może być przechowywana. + Zablokuj + Usuń + Zablokuj + Zgłoś + Usuń Nie pokazuj ponownie + Spam lub oszustwa + Wulgaryzmy lub nienawistne zachowanie + Złośliwe podszywanie się + Nagość lub zawartość graficzna + Nielegalne zachowanie + Zablokowanie użytkownika ukryje jego zawartość w aplikacji. Twoje notatki są nadal widoczne publicznie, w tym dla osób, które blokujesz. Zablokowani użytkownicy są wymienieni na ekranie filtrów bezpieczeństwa. + + Zgłoś nadużycie + Dodatkowe informacje + Powód + Wybierz powód… + Wyślij zgłoszenie + Zablokuj i zgłoś + Zablokuj + Zakładki + Projekty + Prywatne Zakładki + Publiczne zakładki + Dodaj do prywatnych zakładek + Dodaj do publicznych zakładek + Usuń z prywatnych zakładek + Usuń z publicznych zakładek + Pokaż tajny klucz + (0–100)% Dodaj wiadomość publiczną Dodaj prywatną wiadomość Dziękujemy za całą twoją pracę! @@ -97,13 +195,103 @@ Nadawca i odbiorca mogą zobaczyć się nawzajem i przeczytać wiadomość Twoje retransmitery (NIP-95) Pliki są przechowywane przez Twoje retransmitery. Nowy NIP: sprawdź, czy jest obsługiwany + Twoje dane zostaną natychmiast przekazane w ramach zwykłej sieci + Tak + Nie + Lista obserwowanych + Nieprawidłowy numer portu + Prywatne Wiadomości + Powiadamia Cię, gdy nadejdzie prywatna wiadomość + Otrzymano Zapy + Powiadamia Cię, gdy ktoś prześle ci zapy + Dołącz do rozmowy + ID Użytkownika lub Grupy + npub, nevent lub hex + Utwórz + Dołącz + Dzisiaj + Ostrzeżenie o zawartości + Ten post zawiera wrażliwe treści, które niektóre osoby mogą uznać za obraźliwe lub niepokojące + Zawsze ukrywaj wrażliwe treści + Zawsze pokazuj wrażliwą zawartość + Zawsze pokazuj ostrzeżenia dotyczące zawartości + Filtrowanie spamu od nieznajomych + Ostrzegaj, gdy posty zostały zgłoszone przez osoby które obserwujesz Odczytaj z Retransmitera Zapisz do Retransmitera Wystąpił błąd podczas próby uzyskania informacji o retransmiterze z %1$s + Właściciel + Wersja + Oprogramowanie + Kontakt + Adres URL płatności + Ograniczenia + Kraje + Języki + Polityka publikowania + Długość wiadomości + Subskrybcje + Filtry Retransmitery + Społeczność + Czaty + Zatwierdzone posty + Ta grupa nie ma opisu ani reguł. Porozmawiaj z właścicielem, aby je dodać + Ta społeczność nie ma opisu. Porozmawiaj z właścicielem, aby go dodać + Treść wrażliwa + Dodaje ostrzeżenie o wrażliwej treści przed wyświetleniem tej zawartości + Ustawienia + Zawsze + Tylko WiFi + Nigdy + Język + Motyw + Podgląd obrazu + Odtwarzanie wideo + Podgląd URL + Załaduj obraz + Spamerzy + Wyciszone. Kliknij, aby wyłączyć wyciszenie + Dźwięk włączony. Kliknij, aby wyciszyć + Adres Nostr został zweryfikowany + Nieudana weryfikacja adresu Nostr + Sprawdzanie adresu Nostr + Zaznacz/Odznacz wszystko Wybierz retransmiter, aby kontynuować + Przekaż zapy do: + Do + Temat + Temat dyskusji + "\@Użytkownik1, @Użytkownik2, @Użytkownik3" + Członkowie tej grupy + Kopiuj do schowka + Kopiuj npub do schowka + Kopiuj adres URL do schowka + Kopiuj ID notatki do schowka + Zaktualizuj status + Błąd podczas analizowania komunikatu o błędzie + 25 + Zdjęcie profilowe + Pokaż zdjęcia profilowe + Wybierz opcję Nie można pobrać dokumentu retransmitera + iPhone 13 + Cena (w Satach) + Miasto, Województwo, Kraj + Akcesoria + Elektronika + Meble + Książki + Zwierzęta domowe + Sporty + Fitness + Sztuka + Biuro Retransmiter %1$s Rozwiń listę retransmiterów Wybór listy retransmiterów + Nieprawidłowy adres + Skonfiguruj teraz + Dziękuję! + Maksymalny Limit diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9701422ef..2cf9242a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -490,6 +490,7 @@ Relays + Note Discovery Marketplace Live Community @@ -845,4 +846,7 @@ Draft Note From Msg + + Waiting for DVM to reply + DVM seems not to reply diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt index 8374c518d..f0cf4a7c3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppDefinitionEvent.kt @@ -22,11 +22,94 @@ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.fasterxml.jackson.annotation.JsonProperty import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import java.io.ByteArrayInputStream +@Stable +class AppMetadata { + var name: String? = null + var username: String? = null + + @JsonProperty("display_name") + var displayName: String? = null + var picture: String? = null + + var banner: String? = null + var image: String? = null + var website: String? = null + var about: String? = null + var subscription: Boolean? = false + var cashuAccepted: Boolean? = false + var encryptionSupported: Boolean? = false + var personalized: Boolean? = false + var amount: String? = null + + var nip05: String? = null + var domain: String? = null + var lud06: String? = null + var lud16: String? = null + + var twitter: String? = null + + @Transient + var tags: ImmutableListOfLists? = null + + fun anyName(): String? { + return displayName ?: name ?: username + } + + fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(name, username, displayName, nip05, lud06, lud16).any { + it.contains(prefix, true) + } + } + + fun lnAddress(): String? { + return lud16 ?: lud06 + } + + fun bestName(): String? { + return displayName ?: name ?: username + } + + fun nip05(): String? { + return nip05 + } + + fun profilePicture(): String? { + return picture + } + + fun cleanBlankNames() { + if (picture?.isNotEmpty() == true) picture = picture?.trim() + if (nip05?.isNotEmpty() == true) nip05 = nip05?.trim() + + if (displayName?.isNotEmpty() == true) displayName = displayName?.trim() + if (name?.isNotEmpty() == true) name = name?.trim() + if (username?.isNotEmpty() == true) username = username?.trim() + if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim() + if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim() + + if (website?.isNotEmpty() == true) website = website?.trim() + if (domain?.isNotEmpty() == true) domain = domain?.trim() + + if (picture?.isBlank() == true) picture = null + if (nip05?.isBlank() == true) nip05 = null + if (displayName?.isBlank() == true) displayName = null + if (name?.isBlank() == true) name = null + if (username?.isBlank() == true) username = null + if (lud06?.isBlank() == true) lud06 = null + if (lud16?.isBlank() == true) lud16 = null + + if (website?.isBlank() == true) website = null + if (domain?.isBlank() == true) domain = null + } +} + @Immutable class AppDefinitionEvent( id: HexKey, @@ -36,7 +119,7 @@ class AppDefinitionEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var cachedMetadata: UserMetadata? = null + @Transient private var cachedMetadata: AppMetadata? = null fun appMetaData() = if (cachedMetadata != null) { @@ -46,7 +129,7 @@ class AppDefinitionEvent( val newMetadata = mapper.readValue( ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)), - UserMetadata::class.java, + AppMetadata::class.java, ) cachedMetadata = newMetadata 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 849b0ea1a..d639cbc37 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -114,6 +114,11 @@ class EventFactory { MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) + NIP90StatusEvent.KIND -> NIP90StatusEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ContentDiscoveryRequestEvent.KIND -> NIP90ContentDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90ContentDiscoveryResponseEvent.KIND -> NIP90ContentDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) + NIP90UserDiscoveryRequestEvent.KIND -> NIP90UserDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) + NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryRequestEvent.kt new file mode 100644 index 000000000..d205a743b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryRequestEvent.kt @@ -0,0 +1,56 @@ +/** + * 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 androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Stable +@Immutable +class NIP90ContentDiscoveryRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 5300 + + fun create( + addressedDVM: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit, + ) { + val content = "" + val tags = mutableListOf>() + tags.add(arrayOf("p", addressedDVM)) + tags.add(arrayOf("alt", "NIP90 Content Discovery request")) + tags.add(arrayOf("client", "Amethyst")) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryResponseEvent.kt new file mode 100644 index 000000000..8f76eb125 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryResponseEvent.kt @@ -0,0 +1,53 @@ +/** + * 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.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NIP90ContentDiscoveryResponseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 6300 + const val ALT = "NIP90 Content Discovery reply" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppRecommendationEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", ALT), + ) + signer.sign(createdAt, KIND, tags, "", onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90StatusEvent.kt new file mode 100644 index 000000000..ad34650f1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90StatusEvent.kt @@ -0,0 +1,53 @@ +/** + * 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.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NIP90StatusEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 7000 + const val ALT = "NIP90 Status update" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppRecommendationEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", ALT), + ) + signer.sign(createdAt, KIND, tags, "", onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90UserDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90UserDiscoveryRequestEvent.kt new file mode 100644 index 000000000..493cbf407 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90UserDiscoveryRequestEvent.kt @@ -0,0 +1,53 @@ +/** + * 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.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NIP90UserDiscoveryRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 5301 + const val ALT = "NIP90 Content Discovery request" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppRecommendationEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", ALT), + ) + signer.sign(createdAt, KIND, tags, "", onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90UserDiscoveryResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90UserDiscoveryResponseEvent.kt new file mode 100644 index 000000000..f1978ca97 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90UserDiscoveryResponseEvent.kt @@ -0,0 +1,53 @@ +/** + * 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.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NIP90UserDiscoveryResponseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 6301 + const val ALT = "NIP90 Content Discovery reply" + + fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppRecommendationEvent) -> Unit, + ) { + val tags = + arrayOf( + arrayOf("alt", ALT), + ) + signer.sign(createdAt, KIND, tags, "", onReady) + } + } +}