feat: align leave vs remove-from-messages actions across chat types

"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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRifAJ75U4zWbg3V3Y3g8g
This commit is contained in:
Claude
2026-07-28 18:40:16 +00:00
parent 84a96f1f86
commit 49e4234f99
6 changed files with 162 additions and 66 deletions
@@ -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
@@ -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,
@@ -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,
@@ -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()
},
)
}
@@ -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,
)
}
+1 -1
View File
@@ -448,6 +448,7 @@
<string name="refresh">Refresh</string>
<string name="changed_chat_profile_to">New chat profile:</string>
<string name="leave">Leave</string>
<string name="remove_from_messages">Remove from Messages</string>
<string name="unfollow">Unfollow</string>
<string name="channel_created">Channel created</string>
<string name="channel_information_changed_to">"Channel Information changed to"</string>
@@ -3535,7 +3536,6 @@
<string name="buzz_dm_empty_title">No direct messages yet</string>
<string name="buzz_dm_empty_body">Start a private conversation with anyone on a Buzz workspace.</string>
<string name="buzz_dm_more">More</string>
<string name="buzz_dm_hide">Hide conversation</string>
<string name="buzz_dm_add_member">Add member</string>
<string name="buzz_dm_add_member_title">Add someone to this DM</string>
<string name="buzz_dm_add_member_invalid">Not a valid npub or hex key</string>