From bb87d7755ad832fdbc1118cf99e50431343c4dfa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 12:44:36 -0400 Subject: [PATCH] feat(buzz): ask before showing channels somebody added you to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Buzz relay, channel membership is server-side: another member can add you, the relay writes you into the kind-39002 roster, and you can read and post immediately. The relay then addresses you a kind-44100 naming who did it. Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which silently subscribed you to that channel's messages, while the Messages list (which reads the self-published kind-10009) showed no row for it. A channel could therefore be joined, streaming, and invisible at the same time: the channel screen offered no Join button and accepted posts, the RelayGroups screen listed it from the relay's 39000 directory, messages arrived — and Messages had nothing. Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id", "actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`, so the actor is the only thing separating "I joined this" from "somebody put me here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/ workflow — read through a dedicated accessor because on buzz the type shares the tag name with real hashtags): only `t = dm` belongs in the DM list, everything else becomes a pending invite that subscribes to nothing. The prompt appears on both surfaces, driven by one state holder so they cannot disagree — Notifications, in the same header slot as the missing-inbox-relay prompt, and Messages > New Requests, beside the pending DMs it is the exact analogue of. Rendered as a list row rather than a modal: these arrive in bursts when somebody sets up a workspace, and a blocking dialog on cold start would be miserable. It is also the spam surface, so Ignore stays cheap. Three actions, and Ignore is deliberately not Leave: - Show -> writes the group into kind-10009 (Account.follow), after which the ordinary joined-group path owns it and it syncs to other devices. No kind-9021: the relay already has you in the roster, so this records only your decision to surface it. - Ignore -> local, reversible display choice. You stay in the roster and can still open and post. - Leave -> kind-9022 LeaveRequestEvent, the one that actually removes you. A kind-44101 removal now withdraws any pending prompt, so the relay taking the membership away cannot leave a card offering an action that would fail. The invites section is passed as the chatroom feed's header rather than stacked beside it: the collapsing top bar draws over that area, so a header outside the list renders underneath it. It shows in the empty state too, otherwise an account with no pending DMs would have no way to reach the prompt. Verified end to end on device: "straycat added you to personalized-knowledge- graphs" rendered on both surfaces, and Show republished kind-10009 with the channel appended. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/LocalPreferences.kt | 4 + .../amethyst/model/AccountSettings.kt | 27 ++++ .../amethyst/model/LocalCache.kt | 19 ++- .../loggedIn/AccountFeedContentStates.kt | 4 + .../ui/screen/loggedIn/AccountViewModel.kt | 29 ++++ .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 70 +++++++++- .../chats/rooms/feed/ChatroomListFeedView.kt | 28 +++- .../chats/rooms/feed/ChatroomListTabs.kt | 12 ++ .../notifications/ChannelInvitesSection.kt | 129 ++++++++++++++++++ .../notifications/ChannelInvitesState.kt | 64 +++++++++ .../notifications/NotificationScreen.kt | 7 +- amethyst/src/main/res/values/strings.xml | 6 + .../commons/model/buzz/BuzzChannelInvites.kt | 111 +++++++++++++++ .../commons/model/buzz/BuzzDmChannels.kt | 17 +++ .../MemberAddedNotificationEvent.kt | 10 ++ .../MemberRemovedNotificationEvent.kt | 10 ++ .../MembershipNotificationContent.kt | 81 +++++++++++ .../metadata/GroupMetadataEvent.kt | 24 ++++ 18 files changed, 641 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelInvites.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/notifications/MembershipNotificationContent.kt 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(