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 6e2e617ddc..b105993fc6 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 @@ -1689,17 +1689,25 @@ class AccountViewModel( } /** - * 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. + * Put a relay group (back) on Messages: write it into my kind-10009 so it shows and follows me to + * other devices, and clear the dismissal [removeRelayGroupFromMessages] left behind so a Buzz relay + * re-announcing my membership isn't filtered out. No kind-9021 join — the relay roster is untouched + * by both halves of this toggle; this only records *my* decision to surface the channel. */ - fun acceptChannelInvite(channel: RelayGroupChannel) = + fun addRelayGroupToMessages(channel: RelayGroupChannel) = launchSigner { account.settings.undismissChannelInvite(channel.groupId.id) account.follow(channel) BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) } + /** + * Accept a channel somebody added me to. Identical to [addRelayGroupToMessages] — accepting an + * invite *is* surfacing the channel, since the relay already put me in the roster (which is why + * the channel opens and accepts posts today). + */ + fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel) + /** * 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. 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 677bbec693..da30fcc4dc 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 @@ -56,7 +56,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -82,6 +84,12 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import kotlinx.coroutines.launch +/** + * Bottom room the inbox leaves for its extended FAB — same clearance the community view uses, and + * wide enough for the taller extended variant. + */ +private val FAB_CLEARANCE = 96.dp + /** * The **Buzz Direct Messages** inbox. A Buzz DM is a relay-authoritative NIP-29 group * whose id is a UUID, so tapping a row opens the very same [Route.RelayGroup] chat screen @@ -99,6 +107,11 @@ fun BuzzDmListScreen( viewModel.bind(accountViewModel.account, relayUrl) val rows by viewModel.rows.collectAsStateWithLifecycle() + val hiddenRows by viewModel.hiddenRows.collectAsStateWithLifecycle() + + // Hidden conversations stay collapsed behind a header — they are off Messages by the user's own + // choice, so they must not compete with the live inbox; they only need to be *reachable* again. + var showHidden by remember { mutableStateOf(false) } Scaffold( topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_title), nav) }, @@ -110,26 +123,79 @@ fun BuzzDmListScreen( ) }, ) { padding -> - if (rows.isEmpty()) { + if (rows.isEmpty() && hiddenRows.isEmpty()) { EmptyDmInbox(modifier = Modifier.padding(padding)) } else { LazyColumn( modifier = Modifier.padding(padding).fillMaxSize(), - contentPadding = PaddingValues(16.dp), + // Extra room at the bottom so the last row's overflow clears the FAB, which the + // Scaffold's padding deliberately doesn't account for (a FAB overlays content). + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = FAB_CLEARANCE), verticalArrangement = Arrangement.spacedBy(10.dp), ) { items(rows, key = { it.channelId }) { row -> - DmRowCard(row, accountViewModel, nav) + DmRowCard(row, isHidden = false, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav) + } + if (hiddenRows.isNotEmpty()) { + item(key = "hidden-header") { + HiddenDmHeader( + count = hiddenRows.size, + expanded = showHidden, + onToggle = { showHidden = !showHidden }, + ) + } + if (showHidden) { + items(hiddenRows, key = { "hidden-${it.channelId}" }) { row -> + DmRowCard(row, isHidden = true, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav) + } + } } } } } } +/** + * The collapsible "Hidden (N)" divider between the live inbox and the conversations I took off it. + * Shared with the community view's inline Direct Messages section, so a hidden DM is reachable from + * wherever the user's DMs are — this inbox screen is only reachable behind a "see all" row. + */ +@Composable +fun HiddenDmHeader( + count: Int, + expanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + symbol = if (expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Text( + text = pluralStringResource(R.plurals.buzz_dm_hidden_count, count, count), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + /** One conversation: a participant avatar (stacked for group DMs), names, host, last-seen, kebab. */ @Composable private fun DmRowCard( row: BuzzDmListViewModel.DmRow, + isHidden: Boolean, + viewModel: BuzzDmListViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -156,7 +222,9 @@ private fun DmRowCard( modifier = Modifier.fillMaxWidth(), ) { Row( - modifier = Modifier.padding(12.dp), + // A hidden DM is still openable (it's a live conversation I merely took off the list), so + // it renders faded rather than disabled — visibly parked, not broken. + modifier = Modifier.padding(12.dp).alpha(if (isHidden) 0.55f else 1f), verticalAlignment = Alignment.CenterVertically, ) { DmAvatars(row.others, accountViewModel, nav) @@ -204,21 +272,21 @@ private fun DmRowCard( addMemberOpen = true }, ) + // A toggle, like the channel rows: hiding a DM is a per-viewer, reversible + // relay-side flag (kind-41012 / the 30622 snapshot), never a departure — so a + // hidden conversation must offer its own way back rather than vanishing for good. DropdownMenuItem( - text = { Text(stringRes(R.string.remove_from_messages)) }, + text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) }, leadingIcon = { Icon( - symbol = MaterialSymbols.VisibilityOff, + symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff, contentDescription = null, modifier = Modifier.size(20.dp), ) }, onClick = { menuOpen = false - scope.launch { - val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) - accountViewModel.account.hideBuzzDm(channel) - } + if (isHidden) viewModel.addToMessages(row) else viewModel.removeFromMessages(row) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 2c07d8ccf5..0e3427d717 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -71,8 +71,11 @@ import java.util.concurrent.ConcurrentHashMap * `t` = `dm` — that same 39000 also carries the roster the shared chat composer's member gate * needs, and the DM participants; * - subscribes the per-viewer [DmVisibilityEvent] (`kind:30622`) so a hidden DM (tracked in - * [BuzzDmRegistry]) drops out; - * - projects the visible DMs into [rows], sorted by last message time. + * [BuzzDmRegistry]) moves from [rows] to [hiddenRows]; + * - projects the visible DMs into [rows] and the hidden ones into [hiddenRows], both sorted by + * last message time. Hidden DMs stay projected (rather than being dropped on the floor) so the + * inbox can offer them back — hiding is reversible, and a conversation with no way back is a + * conversation the user has lost. */ class BuzzDmListViewModel : ViewModel() { @Volatile private var account: Account? = null @@ -88,6 +91,10 @@ class BuzzDmListViewModel : ViewModel() { private val _rows = MutableStateFlow>(emptyList()) val rows: StateFlow> = _rows.asStateFlow() + /** The DMs I hid (per the relay's 30622 snapshot), newest-first — offered back under "Hidden". */ + private val _hiddenRows = MutableStateFlow>(emptyList()) + val hiddenRows: StateFlow> = _hiddenRows.asStateFlow() + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() @@ -211,14 +218,17 @@ class BuzzDmListViewModel : ViewModel() { account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } } - /** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */ + /** + * Project the discovered DM channels (metadata `t` = `dm`) newest-first, split by my hidden set + * (the relay's 30622 snapshot) into [rows] and [hiddenRows]. Both halves come from one pass so a + * DM can only ever be in one of them. + */ private fun rebuildRows(account: Account) { val myPubkey = account.userProfile().pubkeyHex val hidden = BuzzDmRegistry.hiddenFor(myPubkey) - _rows.value = + val (hiddenDms, visibleDms) = memberChannels.entries .mapNotNull { (channelId, relay) -> - if (channelId in hidden) return@mapNotNull null val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay)) val metadata = channel.event ?: return@mapNotNull null if (!metadata.isBuzzDm()) return@mapNotNull null @@ -231,6 +241,38 @@ class BuzzDmListViewModel : ViewModel() { lastActivity = lastActivityFor(channelId), ) }.sortedByDescending { it.lastActivity } + .partition { it.channelId in hidden } + _rows.value = visibleDms + _hiddenRows.value = hiddenDms + } + + /** + * Take [row] off Messages with a kind-41012 hide command. Server-side and per-viewer: the relay + * republishes my 30622 snapshot with this channel in it, which moves the row to [hiddenRows]. + * Membership is untouched — nobody else's inbox changes, and [addToMessages] brings it back. + */ + fun removeFromMessages(row: DmRow) { + val account = account ?: return + viewModelScope.launch(Dispatchers.IO) { + account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl))) + } + } + + /** + * Put a hidden DM back on Messages. Buzz has no "unhide" command — re-opening the conversation is + * the un-hide: a kind-41010 with the same participants resolves to the same canonical channel and + * drops it from the 30622 hidden snapshot. A self-DM has no `others`, so send myself, which is + * what the relay derived that channel from (and satisfies kind-41010's 1-8 participant rule). + */ + fun addToMessages(row: DmRow) { + val account = account ?: return + viewModelScope.launch(Dispatchers.IO) { + val me = account.userProfile().pubkeyHex + account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) }) + // The relay's new 30622 normally arrives on the live subscription; refresh anyway so the + // row returns even if this screen's socket missed the snapshot. + refresh() + } } /** Newest message `created_at` for [channelId] from [LocalCache], or 0 when the DM is empty. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 06486b8b0d..74a722e0d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -84,7 +84,8 @@ private const val CARD_WARMUP_LIMIT = 10 * recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity * summary for system/diff/job rows), the relative time of that message, and an unread-count badge. * Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the - * per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean. + * per-channel actions — Pin/Unpin and the Add/Remove-from-Messages toggle ([isAdded] says which half + * to show, and it must come from the live kind-10009 list) — so the row stays clean. * * Reused by the relay group-list screen where Buzz membership discovery is folded in. * @@ -99,6 +100,7 @@ fun BuzzImportRow( groupId: GroupId, isAdded: Boolean, onAdd: () -> Unit, + onRemove: () -> Unit, accountViewModel: AccountViewModel, onOpen: (() -> Unit)? = null, isStarred: Boolean = false, @@ -168,6 +170,7 @@ fun BuzzImportRow( isStarred = isStarred, onToggleStar = onToggleStar, onAdd = onAdd, + onRemove = onRemove, accountViewModel = accountViewModel, ) } @@ -192,6 +195,7 @@ private fun BuzzImportRowContent( isStarred: Boolean, onToggleStar: (() -> Unit)?, onAdd: () -> Unit, + onRemove: () -> Unit, accountViewModel: AccountViewModel, ) { Row( @@ -249,6 +253,7 @@ private fun BuzzImportRowContent( BuzzChannelRowMenu( isAdded = isAdded, onAdd = onAdd, + onRemove = onRemove, isStarred = isStarred, onToggleStar = onToggleStar, ) @@ -307,6 +312,7 @@ private fun BuzzChannelPreviewLine( private fun BuzzChannelRowMenu( isAdded: Boolean, onAdd: () -> Unit, + onRemove: () -> Unit, isStarred: Boolean, onToggleStar: (() -> Unit)?, ) { @@ -338,20 +344,22 @@ private fun BuzzChannelRowMenu( }, ) } + // A toggle, not a one-way "Added" badge: a channel already on the kind-10009 list offers + // the way back off it. Neither half touches the relay roster, so the channel stays in this + // list (and readable) either way — only whether it shows on Messages changes. DropdownMenuItem( leadingIcon = { Icon( - symbol = if (isAdded) MaterialSymbols.Check else MaterialSymbols.Add, + symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add, contentDescription = null, - tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) }, - text = { Text(stringRes(if (isAdded) R.string.buzz_import_added else R.string.buzz_import_add)) }, - enabled = !isAdded, + text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) }, onClick = { expanded = false - onAdd() + if (isAdded) onRemove() else onAdd() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 114189c18e..92027d1cd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -39,7 +39,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.Collections @@ -79,7 +78,12 @@ class BuzzRelayImportViewModel : ViewModel() { private val _channels = MutableStateFlow>(emptyList()) val channels: StateFlow> = _channels.asStateFlow() - /** Channel ids already present in the user's kind-10009 list (seeded + updated as they add). */ + /** + * Channel ids on this relay that are currently in the user's kind-10009 list. Mirrors the live + * list rather than snapshotting it: it used to be seeded once at [bind] and only ever grow, so a + * channel taken off Messages anywhere else still rendered as "Added" — with the Add action + * disabled, leaving no way back. + */ private val _added = MutableStateFlow>(emptySet()) val added: StateFlow> = _added.asStateFlow() @@ -110,12 +114,13 @@ class BuzzRelayImportViewModel : ViewModel() { // every other `#p=me`-gated read on the shared socket. if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) - // Seed "already added" from the current kind-10009 list so channels the user already has - // render as added rather than offering a duplicate Add. - _added.value = - account.relayGroupList.liveRelayGroupList.value - .filter { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == normalized } - .mapTo(mutableSetOf()) { it.groupId } + // Track "already added" against the live kind-10009 list, scoped to this relay, so the rows + // follow every add/remove — from here, from the channel's top bar, or from another device. + viewModelScope.launch(Dispatchers.IO) { + account.relayGroupList.liveRelayGroupIds.collect { groups -> + _added.value = groups.filter { it.relayUrl == normalized }.mapTo(mutableSetOf()) { it.id } + } + } discover(account, normalized) } @@ -176,13 +181,32 @@ class BuzzRelayImportViewModel : ViewModel() { } } - /** Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages. */ + /** + * Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages, and + * clear any earlier dismissal so the relay's kind-44100 re-announcement isn't filtered back out. + * [_added] is not touched here — the live-list collector in [bind] reflects the new event. + */ fun add(groupId: GroupId) { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + account.settings.undismissChannelInvite(groupId.id) account.follow(channel) - _added.update { it + groupId.id } + } + } + + /** + * Take [groupId] off the kind-10009 list without leaving the channel: no kind-9022, so the relay + * roster (and therefore this very list of channels) is untouched and it can be added back. The + * dismissal keeps a Buzz relay's kind-44100 re-announcement from bouncing it back as an invite — + * mirrors `AccountViewModel.removeRelayGroupFromMessages`. + */ + fun remove(groupId: GroupId) { + val account = account ?: return + viewModelScope.launch(Dispatchers.IO) { + val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + account.settings.dismissChannelInvite(groupId.id) + account.unfollow(channel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 40bf7b5810..57cf317c83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +37,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -53,6 +56,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.pluralStringResource @@ -90,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.HiddenDmHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription @@ -113,6 +118,13 @@ private const val CHANNEL_LIST_WARMUP_LIMIT = 10 /** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */ private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L +/** + * Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin + * + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches + * the value `JobBoardScreen` already uses. + */ +private val FAB_CLEARANCE = 96.dp + /** * Lists every channel a relay hosts (its kind 39000-39003 directory), so the user * can browse and open channels on that relay. The relay's directory is streamed by @@ -200,6 +212,12 @@ fun RelayGroupChannelListScreen( val dmVm: BuzzDmListViewModel = viewModel(key = "BuzzDmInline-${relay.url}") LaunchedEffect(relay, isBuzz) { if (isBuzz) dmVm.bind(accountViewModel.account, relay.url) } val dmRows by dmVm.rows.collectAsStateWithLifecycle() + val hiddenDmRows by dmVm.hiddenRows.collectAsStateWithLifecycle() + // DMs I took off Messages, parked behind a collapsed "Hidden (N)" tail below the section. They + // live here and not only on the full inbox screen because that screen sits behind a "see all" + // row that never appears until a community has more DMs than fit inline — so without this, a + // hidden DM in a small workspace would have no way back at all. + var showHiddenDms by remember { mutableStateOf(false) } // A Buzz workspace's channels come in three flavours, distinguished by the relay-signed 39000 // `channel_type`: chat "stream" channels, "forum" channels (threaded posts), and "dm" channels @@ -345,7 +363,14 @@ fun RelayGroupChannelListScreen( } } } else { - LazyColumn(modifier = Modifier.padding(padding)) { + // The Scaffold's `padding` carries the top/bottom bars but deliberately not the FAB — a FAB + // overlays content by design, so clearing it is the list's job. As contentPadding (not a + // modifier) so rows scroll *through* that strip and only come to rest clear of it; the + // modifier form would shrink the viewport and leave the FAB floating over dead space. + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(bottom = FAB_CLEARANCE), + ) { if (showTorHint) { item(key = "tor-hint") { TorClearnetBanner( @@ -391,6 +416,7 @@ fun RelayGroupChannelListScreen( groupId = groupId, isAdded = groupId.id in buzzAdded, onAdd = { buzzVm.add(groupId) }, + onRemove = { buzzVm.remove(groupId) }, accountViewModel = accountViewModel, onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) }, isStarred = groupId.id in starred, @@ -416,6 +442,7 @@ fun RelayGroupChannelListScreen( groupId = groupId, isAdded = groupId.id in buzzAdded, onAdd = { buzzVm.add(groupId) }, + onRemove = { buzzVm.remove(groupId) }, accountViewModel = accountViewModel, // A forum channel's primary content is its threads (kind-45001 posts), not a // kind-9 chat, so open the forum/threads view directly instead of the chat. @@ -455,7 +482,14 @@ fun RelayGroupChannelListScreen( } else { val shown = dmRows.take(INLINE_DM_LIMIT) items(shown, key = { "dm-${it.channelId}" }) { row -> - BuzzDmInlineRow(row, myPubkey, accountViewModel, nav) { + BuzzDmInlineRow( + row = row, + myPubkey = myPubkey, + isHidden = false, + onToggleMessages = { dmVm.removeFromMessages(row) }, + accountViewModel = accountViewModel, + nav = nav, + ) { nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url)) } } @@ -468,6 +502,30 @@ fun RelayGroupChannelListScreen( } } } + if (hiddenDmRows.isNotEmpty()) { + item(key = "dm-hidden-header") { + HiddenDmHeader( + count = hiddenDmRows.size, + expanded = showHiddenDms, + onToggle = { showHiddenDms = !showHiddenDms }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (showHiddenDms) { + items(hiddenDmRows, key = { "dm-hidden-${it.channelId}" }) { row -> + BuzzDmInlineRow( + row = row, + myPubkey = myPubkey, + isHidden = true, + onToggleMessages = { dmVm.addToMessages(row) }, + accountViewModel = accountViewModel, + nav = nav, + ) { + nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url)) + } + } + } + } // Agent Console now lives in the community's top-bar overflow menu, not a footer card. } else { // Vanilla NIP-29 relay: flat channel directory (no forums/DMs/console). @@ -572,18 +630,25 @@ private fun RelayGroupSectionHeader( /** * One inline Direct-Message conversation row inside the community view: the counterpart's avatar + - * name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact - * last-activity time. The channel's recent content is warmed while the row is visible so the preview - * fills in ahead of a tap. Tapping opens the DM as its relay-group chat. + * name (or a "+N" cluster label for a group DM), a preview of the last message, a compact + * last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. The channel's + * recent content is warmed while the row is visible so the preview fills in ahead of a tap. Tapping + * opens the DM as its relay-group chat. + * + * [isHidden] renders the row faded and flips the overflow to "Add to Messages" — a hidden DM is a + * live conversation the viewer merely parked, so it stays openable and reversible. */ @Composable private fun BuzzDmInlineRow( row: BuzzDmListViewModel.DmRow, myPubkey: HexKey, + isHidden: Boolean, + onToggleMessages: () -> Unit, accountViewModel: AccountViewModel, nav: INav, onClick: () -> Unit, ) { + var menuOpen by remember { mutableStateOf(false) } val others = row.others.ifEmpty { listOf(myPubkey) } val leadHex = others.first() val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) } @@ -612,7 +677,8 @@ private fun BuzzDmInlineRow( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp) + .alpha(if (isHidden) 0.55f else 1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -640,6 +706,33 @@ private fun BuzzDmInlineRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) }, + onClick = { + menuOpen = false + onToggleMessages() + }, + ) + } + } } } 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 4f83d64fbb..47a19bea25 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 @@ -114,8 +114,11 @@ fun RelayGroupTopBar( val dmOther = if (isDm) channel.event?.buzzParticipants()?.firstOrNull { it != myPubkey } else null var menuOpen by remember { mutableStateOf(false) } + // My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below. + val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds + .collectAsStateWithLifecycle() // 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. + // callback — leaving 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) } @@ -302,15 +305,26 @@ 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. + // Two distinct actions, never conflated: the Messages toggle adds/drops the group + // on my kind-10009 list but keeps my relay membership either way; "Leave" sends + // the kind-9022 that actually removes me. Same split as the channel-invite card. + // + // Reads the live kind-10009 list rather than assuming the group is on it: this + // bar also opens for channels reached from the workspace browse (a Buzz relay + // lists every channel you're a member of, joined or not) and for ones you removed + // earlier — both need the "Add" half. And because it's a reversible toggle, remove + // does NOT pop back: you're still a member reading the channel, and staying is + // what makes the entry flip so the action is visibly undoable. Leave still pops. + val onMyList = channel.groupId in joinedGroupIds DropdownMenuItem( - text = { Text(stringRes(R.string.remove_from_messages)) }, + text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) }, onClick = { menuOpen = false - accountViewModel.removeRelayGroupFromMessages(channel) - if (canPop) nav.popBack() + if (onMyList) { + accountViewModel.removeRelayGroupFromMessages(channel) + } else { + accountViewModel.addRelayGroupToMessages(channel) + } }, ) DropdownMenuItem( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1457b77bdf..25f314256d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -450,6 +450,7 @@ New chat profile: Leave Remove from Messages + Add to Messages Unfollow Channel created "Channel Information changed to" @@ -3586,6 +3587,10 @@ Pin channel Unpin channel No conversations yet + + %1$d hidden conversation + %1$d hidden conversations + See %1$d more conversation See all %1$d conversations diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt index 45d6fc21bd..1ded0b53f5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.utils.Log @@ -97,6 +99,27 @@ class RelayGroupListState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptySet()) + /** + * The joined groups as normalized [GroupId]s — what the UI asks when it needs to know whether a + * channel is on my Messages list ("Remove from Messages" vs "Add to Messages"). The stored tag + * carries a raw relay url string (another client may not have normalized it the way we do), so + * normalize before comparing instead of matching [GroupTag] strings. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val liveRelayGroupIds: StateFlow> = + liveRelayGroupList + .transformLatest { groups -> + emit( + groups.mapNotNullTo(mutableSetOf()) { tag -> + RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { GroupId(tag.groupId, it) } + }, + ) + }.flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + /** Is [groupId] currently on my kind-10009 list (i.e. does it show on Messages)? */ + fun isOnMyList(groupId: GroupId) = groupId in liveRelayGroupIds.value + private fun RelayGroupChannel.toGroupTag() = GroupTag(groupId.id, groupId.relayUrl.url, event?.name()) suspend fun follow(channel: RelayGroupChannel): SimpleGroupListEvent { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEventTest.kt new file mode 100644 index 0000000000..9b46c6fa68 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEventTest.kt @@ -0,0 +1,79 @@ +/* + * 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.nip51Lists.simpleGroupList + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SimpleGroupListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + private val group = GroupTag("abc123", "wss://relay.example.com/", "My Group") + + @Test + fun removeFromListCreatedWithNoPrivateGroups() = + runTest { + // Exactly what RelayGroupListState.follow() does for the very first group. + val created = + SimpleGroupListEvent.create( + publicGroups = listOf(group), + signer = signer, + createdAt = 1740669816, + ) + + assertEquals(1, created.publicGroups().size) + + val removed = + SimpleGroupListEvent.remove( + earlierVersion = created, + group = group, + signer = signer, + createdAt = 1740669817, + ) + + assertEquals(0, removed.publicGroups().size) + } + + @Test + fun removeIgnoresTheCosmeticNameOnTheStoredTag() = + runTest { + val created = + SimpleGroupListEvent.create( + publicGroups = listOf(group), + signer = signer, + createdAt = 1740669816, + ) + + // The channel metadata may have changed names since the tag was written. + val removed = + SimpleGroupListEvent.remove( + earlierVersion = created, + group = GroupTag(group.groupId, group.relayUrl, "Renamed"), + signer = signer, + createdAt = 1740669817, + ) + + assertEquals(0, removed.publicGroups().size) + } +}