From 49e4234f99e87178d8fdd516dcdf316a0d778dd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:40:16 +0000 Subject: [PATCH] feat: align leave vs remove-from-messages actions across chat types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Leave" meant three different things and the actions were scattered across each channel's own screen, so they were hard to find from Messages. Make the vocabulary consistent and reachable: - "Leave" now always means "renounce membership / you're out" — the kind-9022 LeaveRequestEvent for NIP-29/Buzz groups, and the kind-13302 self-list removal for Concord (its only exit). - "Remove from Messages" is the single soft action: take it off my list but keep membership. For a joined relay group this drops the kind-10009 entry without a 9022 (I stay in the roster) and dismisses the invite so a Buzz kind-44100 re-announce can't bounce it back. The Buzz DM "Hide conversation" reuses the same label. Surface both on the Messages rows via long-press (previously only reachable inside each group/community screen): - Relay-group row: "Remove from Messages" + "Leave". - Concord row: "Leave" (reuses the existing confirm dialog; a community has no soft/hard split since the list entry is the whole membership). Split the relay-group top-bar menu into the same two actions, thread an optional onLongClick through ChannelName, and consolidate the buzz_dm_hide string into the shared remove_from_messages string. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NRifAJ75U4zWbg3V3Y3g8g --- .../ui/screen/loggedIn/AccountViewModel.kt | 13 ++ .../screen/loggedIn/buzz/BuzzDmListScreen.kt | 2 +- .../concord/ConcordChannelListScreen.kt | 2 +- .../relayGroup/RelayGroupTopBar.kt | 17 +- .../chats/rooms/ChatroomHeaderCompose.kt | 192 ++++++++++++------ amethyst/src/main/res/values/strings.xml | 2 +- 6 files changed, 162 insertions(+), 66 deletions(-) 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 286030e4f8..c15d31f6d6 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 @@ -1672,6 +1672,19 @@ class AccountViewModel( fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + /** + * Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops + * showing, but send no kind-9022 — I stay in the relay roster and can still read/post, and re-joining + * re-surfaces it instantly. Also records it in `dismissedChannelInvites` so a Buzz relay re-announcing + * my membership (kind-44100) can't bounce it back in as a pending invite. This is the soft counterpart + * to [leaveRelayGroup]; "Remove from Messages" vs "Leave" is the same split the invite card offers. + */ + fun removeRelayGroupFromMessages(channel: RelayGroupChannel) = + launchSigner { + account.settings.dismissChannelInvite(channel.groupId.id) + account.unfollow(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt index e555e14224..677bbec693 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt @@ -205,7 +205,7 @@ private fun DmRowCard( }, ) DropdownMenuItem( - text = { Text(stringRes(R.string.buzz_dm_hide)) }, + text = { Text(stringRes(R.string.remove_from_messages)) }, leadingIcon = { Icon( symbol = MaterialSymbols.VisibilityOff, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index ffe49cce4d..55c7509ee8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -529,7 +529,7 @@ private fun rememberConcordDisplayName( * so leaving is what actually retires the community for them. */ @Composable -private fun ConcordLeaveDialog( +internal fun ConcordLeaveDialog( communityName: String, isOwner: Boolean, onDismiss: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index a4c9c159be..7cbb471afc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -113,6 +113,9 @@ fun RelayGroupTopBar( val dmOther = if (isDm) channel.event?.buzzParticipants()?.firstOrNull { it != myPubkey } else null var menuOpen by remember { mutableStateOf(false) } + // Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu + // callback — leaving/removing a group shouldn't strand the user on the screen of a group they left. + val canPop = nav.canPop() var showInvite by remember { mutableStateOf(false) } var showJoinCode by remember { mutableStateOf(false) } @@ -298,11 +301,23 @@ fun RelayGroupTopBar( }, ) } + // Two distinct actions, never conflated: "Remove from Messages" drops the group + // from my kind-10009 list but keeps my relay membership; "Leave" sends the + // kind-9022 that actually removes me. Same split as the channel-invite card. DropdownMenuItem( - text = { Text(stringRes(R.string.leave)) }, + text = { Text(stringRes(R.string.remove_from_messages)) }, + onClick = { + menuOpen = false + accountViewModel.removeRelayGroupFromMessages(channel) + if (canPop) nav.popBack() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) }, onClick = { menuOpen = false accountViewModel.leaveRelayGroup(channel) + if (canPop) nav.popBack() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 30512735c9..9440d667d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberM import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordLeaveDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel @@ -489,36 +490,61 @@ private fun RelayGroupRoomCompose( // A placeholder row (no messages yet) has a null createdAt and never lights the dot. val lastReadTime by accountViewModel.account.loadLastReadFlow(relayGroupChannelLastReadRoute(channel.groupId)).collectAsStateWithLifecycle() - ChannelName( - channelIdHex = channel.groupId.id, - channelPicture = channelPicture, - channelTitle = { modifier -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { - Text( - text = channel.toBestDisplayName(), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - Spacer(Modifier.width(6.dp)) - RelayNameChip( - label = channel.groupId.relayUrl.displayUrl(), - onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) }, - ) - } - }, - channelLastTime = lastMessage.createdAt(), - channelLastContent = lastContent, - hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = - accountViewModel.settings.autoPlayVideosFlow - .collectAsStateWithLifecycle() - .value, - onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) }, - ) + // Long-press brings the group's membership actions to the Messages row itself, mirroring the group + // top bar so "Remove from Messages" (drop from my list, stay a member) and "Leave" (kind-9022) are + // reachable without opening the group first. + var menuOpen by remember { mutableStateOf(false) } + + Box { + ChannelName( + channelIdHex = channel.groupId.id, + channelPicture = channelPicture, + channelTitle = { modifier -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + Text( + text = channel.toBestDisplayName(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(6.dp)) + RelayNameChip( + label = channel.groupId.relayUrl.displayUrl(), + onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) }, + ) + } + }, + channelLastTime = lastMessage.createdAt(), + channelLastContent = lastContent, + hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) }, + onLongClick = { menuOpen = true }, + ) + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.remove_from_messages)) }, + onClick = { + menuOpen = false + accountViewModel.removeRelayGroupFromMessages(channel) + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + accountViewModel.leaveRelayGroup(channel) + }, + ) + } + } } @Composable @@ -550,40 +576,78 @@ private fun ConcordRoomCompose( .loadLastReadFlow(concordChannelLastReadRoute(channel.channelId.communityId, channel.channelId.channelId)) .collectAsStateWithLifecycle() - ChannelName( - channelIdHex = channel.channelId.channelId, - channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel), - channelTitle = { modifier -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { - Text( - text = channel.toBestDisplayName(), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - channel.communityName?.let { communityName -> - Spacer(Modifier.width(6.dp)) - // The chip names the parent community and, when tapped, opens that community's - // channel list — the "chip that opens the Concord Channel" entry point. - ConcordCommunityPill( - communityName = communityName, - onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) }, + // Concord has no server-side membership beyond my own kind-13302 list, so there is no soft + // "Remove from Messages" distinct from leaving — the only action is "Leave" (drop the community + // from my list = I'm out). Long-press surfaces it on the row with the same confirm the community + // screen uses; leaving a channel row leaves the whole community it belongs to (the dialog names it). + val communityId = channel.channelId.communityId + val isOwner = + accountViewModel.account.concordSessions + .sessionFor(communityId) + ?.entry + ?.owner == accountViewModel.account.signer.pubKey + var menuOpen by remember { mutableStateOf(false) } + var showLeave by remember { mutableStateOf(false) } + + if (showLeave) { + ConcordLeaveDialog( + communityName = channel.communityName ?: channel.toBestDisplayName(), + isOwner = isOwner, + onDismiss = { showLeave = false }, + onConfirm = { + showLeave = false + accountViewModel.leaveConcordCommunity(communityId) + }, + ) + } + + Box { + ChannelName( + channelIdHex = channel.channelId.channelId, + channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel), + channelTitle = { modifier -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + Text( + text = channel.toBestDisplayName(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) + channel.communityName?.let { communityName -> + Spacer(Modifier.width(6.dp)) + // The chip names the parent community and, when tapped, opens that community's + // channel list — the "chip that opens the Concord Channel" entry point. + ConcordCommunityPill( + communityName = communityName, + onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) }, + ) + } } - } - }, - channelLastTime = lastMessage.createdAt(), - channelLastContent = lastContent, - hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = - accountViewModel.settings.autoPlayVideosFlow - .collectAsStateWithLifecycle() - .value, - onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) }, - ) + }, + channelLastTime = lastMessage.createdAt(), + channelLastContent = lastContent, + hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) }, + onLongClick = { menuOpen = true }, + ) + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + showLeave = true + }, + ) + } + } } @Composable @@ -935,6 +999,7 @@ fun ChannelName( loadRobohash: Boolean, autoPlayGif: Boolean, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, ) { ChannelName( channelPicture = { @@ -953,6 +1018,7 @@ fun ChannelName( channelLastContent, hasNewMessages, onClick, + onLongClick, ) } @@ -964,6 +1030,7 @@ fun ChannelName( channelLastContent: String?, hasNewMessages: Boolean, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, ) { ChatHeaderLayout( channelPicture = channelPicture, @@ -998,6 +1065,7 @@ fun ChannelName( } }, onClick = onClick, + onLongClick = onLongClick, ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 04405d49b9..8530ecfd3d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -448,6 +448,7 @@ Refresh New chat profile: Leave + Remove from Messages Unfollow Channel created "Channel Information changed to" @@ -3535,7 +3536,6 @@ No direct messages yet Start a private conversation with anyone on a Buzz workspace. More - Hide conversation Add member Add someone to this DM Not a valid npub or hex key