diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ef75600b76..0193080cf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -206,6 +206,7 @@ private object PrefKeys { const val SIGNER_PACKAGE_NAME = "signer_package_name" const val HAS_DONATED_IN_VERSION = "has_donated_in_version" const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids" + const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites" const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids" const val PENDING_ATTESTATIONS = "pending_attestations" @@ -630,6 +631,7 @@ object LocalPreferences { ) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value) + putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value) putString( PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, JsonMapper.toJson(settings.viewedPollResultNoteIds.value), @@ -744,6 +746,7 @@ object LocalPreferences { val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() + val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() @@ -1000,6 +1003,7 @@ object LocalPreferences { lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), + dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites), viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved), pendingAttestations = MutableStateFlow(pendingAttestationsResolved), backupNipA3PaymentTargets = latestPaymentTargetsResolved, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index cf62e2454c..7b60f85867 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -320,6 +320,12 @@ class AccountSettings( val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val dismissedPollNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), + /** + * Channel ids the viewer chose NOT to show on Messages after somebody added them to the channel + * (kind-44100). Local-only: it records a display preference, not membership — the relay roster + * still lists you, and Leave (kind 9022) is the separate action that actually removes you. + */ + val dismissedChannelInvites: MutableStateFlow> = MutableStateFlow(setOf()), val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, @@ -1521,6 +1527,27 @@ class AccountSettings( } } + // --- + // dismissed channel invites (somebody added me to a channel; I don't want it on Messages) + // --- + + fun isDismissedChannelInvite(channelId: String) = dismissedChannelInvites.value.contains(channelId) + + fun dismissChannelInvite(channelId: String) { + if (!dismissedChannelInvites.value.contains(channelId)) { + dismissedChannelInvites.update { it + channelId } + saveAccountSettings() + } + } + + /** Undo a dismissal — used when the viewer accepts the channel after all, so it can re-prompt later. */ + fun undismissChannelInvite(channelId: String) { + if (dismissedChannelInvites.value.contains(channelId)) { + dismissedChannelInvites.update { it - channelId } + saveAccountSettings() + } + } + // --- // pinned chatrooms // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f90763b71d..7b345d1da4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState @@ -2292,6 +2293,22 @@ object LocalCache : ILocalCache, ICacheProvider { } } + /** + * A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to + * withdraw any pending add-prompt for that channel: once the relay has taken the membership away + * there is nothing left to accept, so leaving the card up would offer an action that cannot succeed. + */ + private fun consume( + event: MemberRemovedNotificationEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = + consumeBuzzRegularEvent(event, relay, wasVerified).also { + val target = event.target() ?: return@also + val channelId = event.channel() ?: return@also + BuzzChannelInvites.remove(target, channelId) + } + /** * Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, … * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic @@ -4821,7 +4838,7 @@ object LocalCache : ILocalCache, ICacheProvider { is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + is MemberRemovedNotificationEvent -> consume(event, relay, wasVerified) is RelayMembershipListEvent -> consume(event, relay, wasVerified) is RelayAddMemberEvent -> consume(event, relay, wasVerified) is RelayRemoveMemberEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 0183867e41..530792d193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicPlaylistsFee import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicTracksFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter @@ -138,6 +139,9 @@ class AccountFeedContentStates( val notificationsEveryone = CardFeedContentState(NotificationFeedFilter(account, TopFilter.Global), scope) val notificationsOpenPolls = OpenPollsState(account, scope) + + /** Channels somebody added the viewer to, awaiting a show-on-Messages decision. */ + val channelInvites = ChannelInvitesState(account, scope) val notificationSummary = NotificationSummaryState(account) val feedListOptions = TopNavFilterState(account, scope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e3502bbf9c..2844127ec5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel @@ -1683,6 +1684,34 @@ class AccountViewModel( fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + /** + * Accept a channel somebody added me to: write it into my kind-10009 so it shows on Messages and + * follows me to other devices. No kind-9021 join — the relay already put me in the roster, which is + * why the channel opens and accepts posts today; this only records *my* decision to surface it. + */ + fun acceptChannelInvite(channel: RelayGroupChannel) = + launchSigner { + account.settings.undismissChannelInvite(channel.groupId.id) + account.follow(channel) + BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) + } + + /** + * Keep the channel off Messages without touching membership. Local and reversible — I stay in the + * roster and can still open and post; [leaveChannelInvite] is the one that actually removes me. + */ + fun dismissChannelInvite(channelId: String) { + account.settings.dismissChannelInvite(channelId) + BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channelId) + } + + /** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */ + fun leaveChannelInvite(channel: RelayGroupChannel) = + launchSigner { + account.leaveRelayGroup(channel) + BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) + } + /** * Drop a Concord community from this account's private kind-13302 list. Fire-and-forget on the * signer dispatcher: the removal lands in the local cache (so the UI updates immediately) and the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt index cc269e445f..6f3ea3d510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent @@ -37,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithH import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -96,10 +100,11 @@ private suspend fun runBuzzDmDiscovery( timeoutMs = 8_000, pendingOnAuthRequired = true, ) { relay, event -> - (event as? MemberAddedNotificationEvent)?.channel()?.let { BuzzDmChannels.record(me, it, relay) } + (event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) } false } fetchDmMetadata(account, me) + classifyDiscoveredChannels(account, me) relays.forEach { relay -> launch { @@ -107,9 +112,12 @@ private suspend fun runBuzzDmDiscovery( account.client.subscribeAsFlow(relay, filter).collect { events -> var changed = false events.filterIsInstance().forEach { e -> - e.channel()?.let { if (BuzzDmChannels.record(me, it, relay)) changed = true } + if (recordDiscovery(me, e, relay)) changed = true + } + if (changed) { + fetchDmMetadata(account, me) + classifyDiscoveredChannels(account, me) } - if (changed) fetchDmMetadata(account, me) } } } @@ -129,3 +137,59 @@ private suspend fun fetchDmMetadata( if (byRelay.isEmpty()) return account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } } + +/** + * Records a kind-44100 "you were added" into [BuzzDmChannels] so its directory can be fetched, and — when + * somebody *else* did the adding — into [BuzzChannelInvites] as well. + * + * The relay emits this same kind for a self-join with `actor == me`, so the actor is what separates "I + * joined this" from "a stranger put me in this". Everything is provisionally treated as a DM here because + * the channel's type only becomes knowable once its kind-39000 lands; [classifyDiscoveredChannels] sorts + * them out immediately afterwards. + */ +private fun recordDiscovery( + me: HexKey, + event: MemberAddedNotificationEvent, + relay: NormalizedRelayUrl, +): Boolean { + val channelId = event.channel() ?: return false + val changed = BuzzDmChannels.record(me, channelId, relay) + val actor = event.actor() + if (actor == null || !actor.equals(me, ignoreCase = true)) { + BuzzChannelInvites.record(me, BuzzChannelInvite(channelId, relay, actor, event.createdAt)) + } + return changed +} + +/** + * Splits what discovery found into DMs and named channels, now that each channel's kind-39000 has loaded. + * + * A `t = dm` channel is a real DM: it stays in [BuzzDmChannels], whose always-on tail keeps it warm so it + * can reach Notifications without being opened, and it is never an "invite". Anything else is a named + * channel somebody added the viewer to; it must NOT be silently subscribed, so it is dropped from + * [BuzzDmChannels] and left in [BuzzChannelInvites] for the viewer to accept or dismiss. + * + * Channels already in the viewer's kind-10009 (accepted earlier, or joined from this device) are not + * invites — the ordinary joined-group path owns them. + */ +private fun classifyDiscoveredChannels( + account: Account, + me: HexKey, +) { + val joined = + account.relayGroupList.liveRelayGroupList.value + .mapNotNullTo(mutableSetOf()) { it.groupId } + + BuzzDmChannels.channelsFor(me).forEach { (channelId, relay) -> + val metadata = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event + when { + // Type not known yet — leave both entries alone and re-run when the directory lands. + metadata == null -> Unit + metadata.isBuzzDmChannel() -> BuzzChannelInvites.remove(me, channelId) + else -> { + BuzzDmChannels.remove(me, channelId) + if (channelId in joined) BuzzChannelInvites.remove(me, channelId) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 217cb50f7d..c8fafc54ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -85,6 +87,13 @@ fun ChatroomListFeedView( scrollStateKey: String, accountViewModel: AccountViewModel, nav: INav, + /** + * Pinned above the rows. Rendered INSIDE the feed rather than beside it so it inherits + * [rememberFeedContentPadding] — the collapsing top bar draws over this area, and a header placed + * outside the list lands underneath it. Shown in every state, so a standing prompt is still + * reachable when the list itself is empty. + */ + headerContent: (@Composable () -> Unit)? = null, ) { DisposableEffect(Unit) { Log.d("DMPagination") { "rooms.list: OPEN" } @@ -92,7 +101,7 @@ fun ChatroomListFeedView( } RefresheableBox(feedContentState, true) { SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> - CrossFadeState(feedContentState, listState, accountViewModel, nav) + CrossFadeState(feedContentState, listState, accountViewModel, nav, headerContent) } } } @@ -103,6 +112,7 @@ private fun CrossFadeState( listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, + headerContent: (@Composable () -> Unit)? = null, ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() @@ -131,10 +141,13 @@ private fun CrossFadeState( ) { state -> when (state) { is FeedState.Empty -> { - if (historyExhausted) { - FeedEmpty { feedContentState.invalidateData() } - } else { - LoadingFeed() + Column(Modifier.padding(rememberFeedContentPadding(FeedPadding))) { + headerContent?.invoke() + if (historyExhausted) { + FeedEmpty { feedContentState.invalidateData() } + } else { + LoadingFeed() + } } } @@ -143,7 +156,7 @@ private fun CrossFadeState( } is FeedState.Loaded -> { - FeedLoaded(state, listState, accountViewModel, nav) + FeedLoaded(state, listState, accountViewModel, nav, headerContent) } FeedState.Loading -> { @@ -159,6 +172,7 @@ private fun FeedLoaded( listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, + headerContent: (@Composable () -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -214,6 +228,8 @@ private fun FeedLoaded( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { + headerContent?.let { item("chatroom-list-header") { it() } } + itemsIndexed( items.list, key = { _, item -> chatroomLazyKey(item, myPubKey) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 157702ec24..fcd6310c2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesSection import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.TabRowHeight @@ -131,6 +132,17 @@ fun MessagesPager( scrollStateKey = tabs[page].scrollStateKey, accountViewModel = accountViewModel, nav = nav, + // Channels somebody added you to are pending decisions, exactly like an unaccepted DM — so + // they belong on New Requests, pinned above the rows. Passed as the feed's header rather + // than stacked beside it: the collapsing top bar draws over this area, so a header outside + // the list renders underneath it. The Notifications tab shows the same prompts from the + // same state holder, so the two surfaces cannot disagree. + headerContent = + if (tabs[page].resource == R.string.new_requests) { + { ChannelInvitesSection(accountViewModel) } + } else { + null + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt new file mode 100644 index 0000000000..caf6d6c1bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 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.notifications + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * "Somebody added you to a channel" prompts, rendered above the Notifications feed (the same header slot + * the missing-inbox-relay prompt uses) and inside Messages › New Requests. + * + * These are deliberately NOT auto-accepted. On a Buzz relay another member can add you to a channel + * server-side: the relay writes you into the kind-39002 roster and you can immediately read and post, + * without you ever agreeing to see it. Amethyst used to silently subscribe to those channels' messages + * while showing no row for them anywhere, so a channel could be joined, streaming, and invisible at once. + * Now the relay's decision is surfaced as a question instead of being acted on. + */ +@Composable +fun ChannelInvitesSection( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val invites by accountViewModel.feedStates.channelInvites.flow + .collectAsStateWithLifecycle() + + if (invites.isEmpty()) return + + Column(modifier) { + invites.forEach { invite -> + ChannelInviteCard(invite, accountViewModel) + } + } +} + +@Composable +fun ChannelInviteCard( + invite: BuzzChannelInvite, + accountViewModel: AccountViewModel, +) { + val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) } + + val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } } + val actorName = actorUser?.let { observeUserName(it, accountViewModel).value } + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Column(Modifier.padding(12.dp)) { + Text( + text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Text( + // Who did it matters: the relay reports a self-join with the same event, so naming the + // actor is what tells "I joined this" apart from "a stranger put me here". + text = + stringRes( + R.string.channel_invite_body, + actorName ?: stringRes(R.string.channel_invite_unknown_actor), + invite.relay.displayUrl(), + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) { + // Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves + // you in the roster, Leave is the kind-9022 that actually removes you from the channel. + TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) { + Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error) + } + TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) { + Text(stringRes(R.string.channel_invite_ignore)) + } + TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) { + Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt new file mode 100644 index 0000000000..7b8c39a911 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 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.notifications + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites +import com.vitorpamplona.amethyst.model.Account +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * The channels somebody else added the viewer to that are still awaiting a decision. + * + * An entry drops out the moment it stops being a question: accepting writes the group into kind-10009 + * (so `joined` covers it and the ordinary Messages row takes over), dismissing records the channel in + * `dismissedChannelInvites`, and leaving makes the relay withdraw the membership. Nothing here asserts + * membership — the relay already granted that — it only tracks whose call it is to surface the channel. + * + * Modelled on [OpenPollsState]: a small always-on projection the Notifications screen and the Messages + * "New Requests" tab both render, so the two surfaces can never disagree about what is pending. + */ +@Stable +class ChannelInvitesState( + private val account: Account, + scope: CoroutineScope, +) { + val flow: StateFlow> = + combine( + BuzzChannelInvites.flow.map { it[account.userProfile().pubkeyHex] ?: emptyMap() }, + account.settings.dismissedChannelInvites, + account.relayGroupList.liveRelayGroupList, + ) { invites, dismissed, joined -> + val joinedIds = joined.mapTo(HashSet()) { it.groupId } + invites.values + .filter { it.channelId !in dismissed && it.channelId !in joinedIds } + .sortedByDescending { it.createdAt } + }.flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index b226f9d3df..627984e45c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -242,7 +242,12 @@ internal fun SingleNotificationsBody( nav = nav, routeForLastRead = NOTIFICATION_LAST_READ_KEY, scrollToEventId = scrollToEventId, - headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, + headerContent = { + ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) + // "X added you to #channel" prompts sit above the feed rather than inside it: they are a + // standing decision, not a dated event, so they must not scroll away into history. + ChannelInvitesSection(accountViewModel) + }, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 650860393c..ebaa131c93 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2653,6 +2653,12 @@ Relays you\'re on Popular relays No messages yet + Added to %1$s + %1$s added you to this channel on %2$s. Show it in Messages? + Someone + Show + Ignore + Leave Join this group to send messages. This group is invite-only — you need an invite to post. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt new file mode 100644 index 0000000000..46f704c4ee --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 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.commons.model.buzz + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Somebody added [viewer] to a channel: who did it, where, and when. */ +@Immutable +class BuzzChannelInvite( + val channelId: String, + val relay: NormalizedRelayUrl, + val actor: HexKey?, + val createdAt: Long, +) + +/** + * App-wide, per-viewer set of channels somebody **else** added the viewer to, awaiting the viewer's + * decision about whether they appear on Messages. + * + * On a Buzz relay, membership is server-side: another member issues the add, the relay writes you into + * the channel's kind-39002 roster, and you can immediately read and post. The relay then addresses you a + * kind-44100 with `{"actor": …}` naming who did it — and it emits the *same* kind for a self-join, with + * `actor == you`, which is the only thing separating the two cases. + * + * Amethyst used to funnel every 44100 into [BuzzDmChannels], which silently subscribed the viewer to the + * channel's messages while the Messages list — which reads the self-published kind-10009 — showed no row + * for it. So a channel could be simultaneously joined (relay roster, no Join button, composer enabled), + * streaming messages, and invisible. Only `t = dm` channels belong in [BuzzDmChannels]; everything else + * lands here until the viewer accepts. + * + * Accepting adds the group to kind-10009 (`Account.follow`), after which the normal joined-group path + * owns it and the entry is dropped. Dismissing is a *display* choice recorded in + * `AccountSettings.dismissedChannelInvites`; genuinely leaving is a kind-9022 `LeaveRequestEvent`, which + * is a different action because the viewer really is a member until the relay says otherwise. + */ +object BuzzChannelInvites { + private val lock = KmpLock() + private val byViewer = HashMap>() + private val mutableFlow = MutableStateFlow>>(emptyMap()) + + /** Per-viewer pending invites (`channelId` -> who/where/when). */ + val flow: StateFlow>> = mutableFlow + + /** + * Records that somebody added [viewer] to [channelId]. Returns true when this is newly seen, so + * callers can invalidate a feed; a repeat of the same (viewer, channel) returns false rather than + * churning the flow — the relay re-sends the notification on every reconnect. + */ + fun record( + viewer: HexKey, + invite: BuzzChannelInvite, + ): Boolean = + lock.withLock { + val invites = byViewer.getOrPut(viewer) { mutableMapOf() } + if (invites.containsKey(invite.channelId)) return@withLock false + invites[invite.channelId] = invite + mutableFlow.value = snapshot() + true + } + + /** + * Drops an invite once it is no longer pending — the viewer accepted it (now in kind-10009), left the + * channel, or the relay reported a kind-44101 removal. + */ + fun remove( + viewer: HexKey, + channelId: String, + ): Boolean = + lock.withLock { + val invites = byViewer[viewer] ?: return@withLock false + if (invites.remove(channelId) == null) return@withLock false + mutableFlow.value = snapshot() + true + } + + /** Invites pending for [viewer], possibly empty. */ + fun invitesFor(viewer: HexKey): Map = mutableFlow.value[viewer] ?: emptyMap() + + private fun snapshot(): Map> = byViewer.mapValues { it.value.toMap() } + + /** Test-only: clears all state so unit tests don't leak into each other. */ + fun clearForTesting() = + lock.withLock { + byViewer.clear() + mutableFlow.value = emptyMap() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt index 0c66f460e2..0f4274e989 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmChannels.kt @@ -68,6 +68,23 @@ object BuzzDmChannels { true } + /** + * Drops a channel that turned out not to be a DM. Discovery provisionally records every kind-44100 + * here so the channel's kind-39000 can be fetched by id; once that reveals a `t` other than `dm` the + * entry is withdrawn, because the always-on DM tail must not silently subscribe the viewer to a named + * channel somebody added them to (see [BuzzChannelInvites]). + */ + fun remove( + viewer: HexKey, + channelId: String, + ): Boolean = + lock.withLock { + val channels = byViewer[viewer] ?: return@withLock false + if (channels.remove(channelId) == null) return@withLock false + mutableFlow.value = snapshot() + true + } + /** The DM channels [viewer] is in (`channelId` -> relay), possibly empty. */ fun channelsFor(viewer: HexKey): Map = mutableFlow.value[viewer] ?: emptyMap() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt index e16a251e84..e4dfbbf685 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberAddedNotificationEvent.kt @@ -49,6 +49,16 @@ class MemberAddedNotificationEvent( /** The channel UUID the member was added to - the `h` tag. */ fun channel() = tags.notificationChannel() + /** + * The relay-reported body: who performed the change ([MembershipNotificationContent.actor]) and the + * channel it applies to. The relay emits this kind for a self-join too, so the actor is what + * separates "I joined" from "somebody added me" — see [MembershipNotificationContent]. + */ + fun notification() = MembershipNotificationContent.parse(content) + + /** The pubkey that performed the add/remove, or null when the body is missing or malformed. */ + fun actor() = notification()?.actor + companion object { const val KIND = 44100 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt index 1d8efb3211..811b39d24d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MemberRemovedNotificationEvent.kt @@ -49,6 +49,16 @@ class MemberRemovedNotificationEvent( /** The channel UUID the member was removed from - the `h` tag. */ fun channel() = tags.notificationChannel() + /** + * The relay-reported body: who performed the change ([MembershipNotificationContent.actor]) and the + * channel it applies to. The relay emits this kind for a self-join too, so the actor is what + * separates "I joined" from "somebody added me" — see [MembershipNotificationContent]. + */ + fun notification() = MembershipNotificationContent.parse(content) + + /** The pubkey that performed the add/remove, or null when the body is missing or malformed. */ + fun actor() = notification()?.actor + companion object { const val KIND = 44101 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt new file mode 100644 index 0000000000..7d57842f0d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 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.buzz.notifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log + +/** + * The JSON body a Buzz relay puts on a kind-44100/44101 membership notification: + * + * ```json + * {"type":"member_added","channel_id":"","actor":""} + * ``` + * + * Ground truth: `emit_membership_notification` in Buzz's `buzz-relay/src/handlers/side_effects.rs`. + * + * [actor] is the decisive field. The relay emits the *same* kind whether somebody added you or you + * joined yourself — a self-join (kind 9021) is reported with `actor == target`. Without reading it, + * "I joined this" and "a stranger put me in this" are indistinguishable, which is what let channels + * appear silently. + */ +class MembershipNotificationContent( + val type: String?, + val channelId: String?, + val actor: HexKey?, +) { + /** True when this records somebody *else* adding [viewer] — i.e. it needs the viewer's consent. */ + fun addedBySomeoneElse(viewer: HexKey): Boolean = actor != null && !actor.equals(viewer, ignoreCase = true) + + companion object { + const val TYPE_MEMBER_ADDED = "member_added" + const val TYPE_MEMBER_REMOVED = "member_removed" + + private val TYPE = Regex("\"type\"\\s*:\\s*\"([^\"]*)\"") + private val CHANNEL_ID = Regex("\"channel_id\"\\s*:\\s*\"([^\"]*)\"") + private val ACTOR = Regex("\"actor\"\\s*:\\s*\"([0-9a-fA-F]{64})\"") + + /** + * Parses the notification body, or null when [content] is blank/unparseable. Regex rather than a + * JSON parse because this runs on the consume path for every membership notification and the + * body is a fixed three-field object written by the relay; a malformed one must degrade to + * "unknown actor" (treated as needing consent) rather than throw. + */ + fun parse(content: String): MembershipNotificationContent? { + if (content.isBlank()) return null + return try { + MembershipNotificationContent( + type = TYPE.find(content)?.groupValues?.get(1), + channelId = CHANNEL_ID.find(content)?.groupValues?.get(1), + actor = + ACTOR + .find(content) + ?.groupValues + ?.get(1) + ?.lowercase(), + ) + } catch (e: Exception) { + Log.w("MembershipNotification") { "Could not parse membership notification: ${e.message}" } + null + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index b708ca5b24..a05f2d7b9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -70,6 +70,25 @@ class GroupMetadataEvent( */ fun geohashes() = tags.geohashes() + /** + * The Buzz channel type, carried as a `t` tag alongside the NIP-29 metadata: + * `stream` (linear chat, the default), `forum`, `dm`, or `workflow`. Ground truth: + * `ChannelType` in Buzz's `buzz-core/src/channel.rs`, emitted by the relay in + * `side_effects.rs` as `["t", channel_type]`. + * + * Read through this rather than [hashtags], which returns every `t` tag: on a Buzz relay the + * type shares the tag name with real hashtags, so only a value in the known set is a type. + * Returns null on a vanilla NIP-29 relay, which has no such concept. + */ + fun buzzChannelType(): String? = tags.firstNotNullOfOrNull { if (it.size > 1 && it[0] == "t" && it[1] in BUZZ_CHANNEL_TYPES) it[1] else null } + + /** + * True when this channel is a Buzz direct-message conversation rather than a named channel. The + * distinction decides whether a kind-44100 "you were added" belongs in the DM list or needs the + * viewer's consent before it shows up on Messages. + */ + fun isBuzzDmChannel() = buzzChannelType() == BUZZ_CHANNEL_TYPE_DM + /** Only members can read. Presence of the `private` flag; absent = public read. */ fun isPrivate() = tags.hasTagName("private") @@ -134,6 +153,11 @@ class GroupMetadataEvent( } companion object { + const val BUZZ_CHANNEL_TYPE_DM = "dm" + + /** Every value Buzz's `ChannelType` can serialize to. */ + val BUZZ_CHANNEL_TYPES = setOf("stream", "forum", BUZZ_CHANNEL_TYPE_DM, "workflow") + const val KIND = 39000 fun build(