feat(buzz): ask before showing channels somebody added you to

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-26 12:44:36 -04:00
co-authored by Claude Opus 5
parent f434e98b00
commit bb87d7755a
18 changed files with 641 additions and 11 deletions
@@ -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,
@@ -320,6 +320,12 @@ class AccountSettings(
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = 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<Set<String>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = 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
// ---
@@ -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)
@@ -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)
@@ -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
@@ -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<MemberAddedNotificationEvent>().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)
}
}
}
}
@@ -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) },
@@ -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
},
)
}
}
@@ -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)
}
}
}
}
}
@@ -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<List<BuzzChannelInvite>> =
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())
}
@@ -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)
},
)
}
}
+6
View File
@@ -2653,6 +2653,12 @@
<string name="relay_group_browse_your_relays">Relays you\'re on</string>
<string name="relay_group_browse_popular">Popular relays</string>
<string name="relay_group_no_messages_yet">No messages yet</string>
<string name="channel_invite_title">Added to %1$s</string>
<string name="channel_invite_body">%1$s added you to this channel on %2$s. Show it in Messages?</string>
<string name="channel_invite_unknown_actor">Someone</string>
<string name="channel_invite_accept">Show</string>
<string name="channel_invite_ignore">Ignore</string>
<string name="channel_invite_leave">Leave</string>
<string name="relay_group_join_to_post">Join this group to send messages.</string>
<string name="relay_group_invite_only_to_post">This group is invite-only — you need an invite to post.</string>
<plurals name="relay_group_member_count">
@@ -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<HexKey, MutableMap<String, BuzzChannelInvite>>()
private val mutableFlow = MutableStateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>>(emptyMap())
/** Per-viewer pending invites (`channelId` -> who/where/when). */
val flow: StateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>> = 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<String, BuzzChannelInvite> = mutableFlow.value[viewer] ?: emptyMap()
private fun snapshot(): Map<HexKey, Map<String, BuzzChannelInvite>> = 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()
}
}
@@ -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<String, NormalizedRelayUrl> = mutableFlow.value[viewer] ?: emptyMap()
@@ -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
@@ -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
@@ -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":"<uuid>","actor":"<pubkey hex>"}
* ```
*
* 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
}
}
}
}
@@ -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(