diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f903682bfc..9f44436f55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences +import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -287,6 +288,11 @@ class AppModules( // Restore + persist the user's starred Buzz workspace channels across restarts (device-global). val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope) + // Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a + // deleted channel stays hidden across a restart even if the host relay re-announces a stale + // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). + val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6c7b0597de..68df7e7ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership @@ -3465,6 +3466,10 @@ class Account( val template = DeleteGroupEvent.build(channel.groupId.id) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } unfollow(channel) + // Remember the deletion so the channel leaves the community's browse list immediately and + // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a + // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. + RelayGroupDeletions.markDeleted(channel.groupId) } /** @@ -3671,6 +3676,10 @@ class Account( parent: String? = channel.parentGroupId(), children: List = channel.childGroupIds(), ) { + // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT + // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on + // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. + val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) val template = EditMetadataEvent.build( channel.groupId.id, @@ -3682,10 +3691,24 @@ class Account( geohashes = geohashes, parent = parent, children = children, + visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** + * Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The + * relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its + * history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces. + */ + suspend fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) { + val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 820cbcaa1a..bdad25dece 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter @@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent @@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { attachToRelayGroupIfScoped(event, relay) } + /** + * A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via + * [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that + * a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery + * events but emits no member-removed notification and never retracts the kind-44100 that seeds the + * browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced + * every restart, its metadata now blank so it shows optimistically). Recording the delete in + * [RelayGroupDeletions] filters it out of every list and persists it across restarts. + * + * Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed + * system message from a stray author can't hide a channel. Cross-device by construction: the relay + * replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too. + */ + private fun consume( + event: SystemMessageEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = + consumeBuzzTimelineEvent(event, relay, wasVerified).also { + if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) { + event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) } + } + } + /** Store-only consume for Buzz kinds that carry no channel timeline row. */ private fun consumeBuzzRegularEvent( event: Event, @@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) + is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt new file mode 100644 index 0000000000..99e1f99c01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt @@ -0,0 +1,77 @@ +/* + * 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.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]), + * so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps + * re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not + * per-account), loads the saved keys into the singleton on construction, then writes every later change + * back. Construct once, eagerly. + */ +@Stable +class RelayGroupDeletionPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + RelayGroupDeletions.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" } + } + } + + private suspend fun persist(keys: Set) { + try { + context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("nip29.deletedChannels") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 14faf2d41c..d520612fcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -790,6 +790,7 @@ fun BuildNavigation( composableFromEndArgs { RelayGroupCreateScreen( relayUrl = it.relayUrl, + isForum = it.isForum, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 57b5af66e8..4392da65e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -741,6 +741,9 @@ sealed class Route { @Serializable data class RelayGroupCreate( val relayUrl: String, + // Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a + // `stream` (chat) one — set by the community screen's per-section "+" buttons. + val isForum: Boolean = false, ) : Route() @Serializable data class RelayGroupEdit( 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 b105993fc6..a247e1cf1e 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 @@ -1675,6 +1675,15 @@ class AccountViewModel( /** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */ fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) } + /** + * Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without + * destroying it, and is reversible. Owner/admin only; the relay enforces it. + */ + fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) = launchSigner { account.archiveRelayGroup(channel, archived) } + /** * 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 @@ -1708,6 +1717,22 @@ class AccountViewModel( */ fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel) + /** + * Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay + * republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it. + */ + fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) } + + /** + * Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with + * the same [participants] (a kind-41010 resolving to the same canonical channel), which drops it + * from the 30622 hidden snapshot. + */ + fun unhideBuzzDm( + relay: NormalizedRelayUrl, + participants: List, + ) = launchSigner { account.openBuzzDm(relay, participants) } + /** * 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/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index a0d45791da..387a77b0eb 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 @@ -32,16 +32,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -83,9 +78,9 @@ private const val CARD_WARMUP_LIMIT = 10 * kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a * 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 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. + * Tapping the card opens the channel ([onOpen]); the row itself is a clean tap-to-open target — its + * per-channel actions (Pin/Unpin, Add/Remove-from-Messages) live in the opened channel's/forum's + * top-bar overflow, not on the row. A pinned channel still shows a pin marker here ([isStarred]). * * Reused by the relay group-list screen where Buzz membership discovery is folded in. * @@ -98,13 +93,9 @@ private const val CARD_WARMUP_LIMIT = 10 @Composable fun BuzzImportRow( groupId: GroupId, - isAdded: Boolean, - onAdd: () -> Unit, - onRemove: () -> Unit, accountViewModel: AccountViewModel, onOpen: (() -> Unit)? = null, isStarred: Boolean = false, - onToggleStar: (() -> Unit)? = null, showActivityPreview: Boolean = true, ) { val account = accountViewModel.account @@ -166,11 +157,7 @@ fun BuzzImportRow( faceAuthors = faceAuthors, unread = unread, hasUnread = hasUnread, - isAdded = isAdded, isStarred = isStarred, - onToggleStar = onToggleStar, - onAdd = onAdd, - onRemove = onRemove, accountViewModel = accountViewModel, ) } @@ -195,15 +182,11 @@ private fun BuzzImportRowContent( faceAuthors: List, unread: Int, hasUnread: Boolean, - isAdded: Boolean, isStarred: Boolean, - onToggleStar: (() -> Unit)?, - onAdd: () -> Unit, - onRemove: () -> Unit, accountViewModel: AccountViewModel, ) { Row( - modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { BuzzImportAvatar(name = name, seed = seed) @@ -254,13 +237,6 @@ private fun BuzzImportRowContent( ConcordUnreadBadge(unread) } } - BuzzChannelRowMenu( - isAdded = isAdded, - onAdd = onAdd, - onRemove = onRemove, - isStarred = isStarred, - onToggleStar = onToggleStar, - ) } } @@ -308,68 +284,6 @@ private fun BuzzChannelPreviewLine( ) } -/** - * The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a - * channel card reads as a clean Concord-style row, with its actions one tap behind the kebab. - */ -@Composable -private fun BuzzChannelRowMenu( - isAdded: Boolean, - onAdd: () -> Unit, - onRemove: () -> Unit, - isStarred: Boolean, - onToggleStar: (() -> Unit)?, -) { - var expanded by remember { mutableStateOf(false) } - Box { - IconButton(onClick = { expanded = true }) { - Icon( - symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.more_options), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if (onToggleStar != null) { - DropdownMenuItem( - leadingIcon = { - Icon( - symbol = MaterialSymbols.PushPin, - contentDescription = null, - tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, - onClick = { - expanded = false - onToggleStar() - }, - ) - } - // 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.VisibilityOff else MaterialSymbols.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) }, - onClick = { - expanded = false - if (isAdded) onRemove() else onAdd() - }, - ) - } - } -} - /** A round monogram whose color is derived deterministically from the channel id. */ @Composable private fun BuzzImportAvatar( 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 92027d1cd2..59a0aa3247 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 @@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions 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.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -154,22 +156,49 @@ class BuzzRelayImportViewModel : ViewModel() { false } - // 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load. + // 2. Fetch each channel's NIP-29 metadata (39000-39003, `#d`-scoped) so its name + Buzz + // `t` type load, AND the relay's kind-40099 system messages (`#h`-scoped) so a + // `channel_deleted` one is seen. The relay soft-deletes a deleted channel's 39000 + // and never retracts the kind-44100 that seeded `channelIds`, so without pulling the + // 40099 a deleted channel — its metadata now blank — would show optimistically here + // forever. LocalCache records the delete into RelayGroupDeletions on consume. if (channelIds.isNotEmpty()) { account.client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))), + filters = + mapOf( + relay to + listOf( + Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())), + Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())), + ), + ), timeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } - // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived - // (type unknown) is optimistically shown as a workspace channel. + // 3. Keep only the non-DM workspace channels the relay still serves metadata for. + // + // A deleted (or never-really-there) channel keeps its kind-44100 — the relay never + // retracts it — but the relay soft-deletes its 39000, so it arrives here with NO + // metadata. That absence is the reliable "it's gone" signal (the relay does not serve + // a `channel_deleted` 40099 for an already-deleted channel, so the fetch above can't + // catch this case). Before, such a channel showed optimistically — as a bare UUID row + // with "No messages yet", which is exactly the leak reported. + // + // So drop channels with no metadata — but ONLY when the fetch clearly worked (at least + // one channel came back with a 39000). If none did, the read failed (auth/connectivity) + // and we keep the whole set rather than blank the community; a genuinely new channel + // whose 39000 is merely slow re-appears on the next bind once its metadata is cached. + val channels = channelIds.associateWith { LocalCache.getOrCreateRelayGroupChannel(GroupId(it, relay)) } + val fetchHadMetadata = channels.values.any { it.event != null } _channels.value = channelIds .mapNotNull { id -> val groupId = GroupId(id, relay) - val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null + val channel = channels.getValue(id) + if (fetchHadMetadata && channel.event == null) return@mapNotNull null if (channel.event?.isBuzzDm() == true) null else groupId }.sortedBy { it.id } _status.value = Status.Ready diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt new file mode 100644 index 0000000000..6240aca33a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt @@ -0,0 +1,104 @@ +/* + * 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.chats.publicChannels.relayGroup + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel + * list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean + * tap-to-open target. Reads the live starred set so the label + icon reflect the current state. + */ +@Composable +fun BuzzPinDropdownItem( + groupId: GroupId, + closeMenu: () -> Unit, +) { + val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle() + val isStarred = groupId.id in starred + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, + onClick = { + closeMenu() + BuzzChannelStars.toggle(groupId.id) + }, + ) +} + +/** + * Add/Remove this relay-group [channel] from my kind-10009 list (whether it shows in Messages). A + * reversible toggle that never touches my relay membership — same split as "Leave" — so it stays + * readable either way. Reads the live kind-10009 list so it flips as the change lands. Moved off the + * per-channel list row into the opened screen's top-bar overflow. + */ +@Composable +fun RelayGroupMessagesDropdownItem( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + closeMenu: () -> Unit, +) { + val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds + .collectAsStateWithLifecycle() + val onMyList = channel.groupId in joinedGroupIds + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) }, + onClick = { + closeMenu() + if (onMyList) { + accountViewModel.removeRelayGroupFromMessages(channel) + } else { + accountViewModel.addRelayGroupToMessages(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 c4d62d47f9..7ff2e28dac 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 @@ -36,8 +36,6 @@ 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 @@ -73,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache @@ -172,6 +171,11 @@ fun RelayGroupChannelListScreen( } } + // Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group — + // but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so + // filter them out everywhere below. Collected as a StateFlow so a delete removes the row live. + val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle() + // Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`). // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if // NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while @@ -179,20 +183,22 @@ fun RelayGroupChannelListScreen( // its de-facto signer — so its relay-signed groups still show while a stray user-published 39000 // (a different author) stays filtered. val channels = - remember(allChannels, relayInfo) { + remember(allChannels, relayInfo, deletedChannels) { val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null - if (nip11Known) { - allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } - } else { - val dominantSigner = - allChannels - .mapNotNull { it.event?.pubKey } - .groupingBy { it } - .eachCount() - .maxByOrNull { it.value } - ?.key - if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels - } + val signed = + if (nip11Known) { + allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } + } else { + val dominantSigner = + allChannels + .mapNotNull { it.event?.pubKey } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels + } + signed.filterNot { it.groupId.toKey() in deletedChannels } } // Buzz relays expose no public group directory (membership is server-side), so `channels` above @@ -200,6 +206,15 @@ fun RelayGroupChannelListScreen( // (kind-44100) so browsing the relay lists the channels you already belong to — each addable to // your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse. val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true + + // A Buzz relay lets any community member create a channel/forum (the creator becomes its owner), + // and the relay rejects a non-member's kind-9007. We don't hard-gate the "+" on membership: the + // NIP-43 roster (kind 13534) isn't fetched on this screen, so gating on it hid the "+" even from + // admins. Instead we offer it on any Buzz community and let the relay enforce — the same approach + // as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them, + // the relay only serves the owner/admin ones." + val myPubkey = accountViewModel.account.signer.pubKey + val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}") LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) } val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle() @@ -226,9 +241,13 @@ fun RelayGroupChannelListScreen( // ids so nothing the old flat list showed disappears. val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } } val buzzGroupIds = - remember(buzzChannels, channels) { + remember(buzzChannels, channels, deletedChannels) { val seen = LinkedHashSet() - (buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) } + // `channels` is already delete-filtered; also drop deleted ids from the membership-scoped + // `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete. + (buzzChannels + channels.map { it.groupId }) + .filterNot { it.toKey() in deletedChannels } + .filter { seen.add(it.id) } } fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType() @@ -245,21 +264,34 @@ fun RelayGroupChannelListScreen( fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id + // Archived channels (relay-signed `archived` tag on the 39000) drop out of their normal section and + // gather in a collapsed "Archived" tail — the same hide-from-the-sidebar behavior the Buzz client + // has. They stay reachable there so an admin can open one and Unarchive it from the top bar. + fun isArchived(groupId: GroupId): Boolean = channelsById[groupId.id]?.isArchived() == true + val buzzChatChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds - .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } } + .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } && !isArchived(it) } .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } val buzzForumChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds - .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM } + .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM && !isArchived(it) } .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } + // Every archived non-DM channel (chat + forum together), newest section at the bottom. + val buzzArchivedChannels = + remember(buzzGroupIds, channelsById) { + buzzGroupIds + .filter { buzzTypeOf(it) != BUZZ_CHANNEL_TYPE_DM && isArchived(it) } + .sortedBy { buzzSortKey(it) } + } - // Which sections the user has collapsed (session-scoped). Keyed by section id below. - var collapsedSections by remember { mutableStateOf(emptySet()) } + // Which sections the user has collapsed (session-scoped). Keyed by section id below. Archived + // starts collapsed — it's the out-of-the-way tail, expanded only when someone goes looking. + var collapsedSections by remember { mutableStateOf(setOf("archived")) } fun toggleSection(key: String) { collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key @@ -326,17 +358,20 @@ fun RelayGroupChannelListScreen( } }, floatingActionButton = { - FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = - stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title), - modifier = Modifier.size(24.dp), - ) + // A Buzz community creates channels/forums from the per-section "+" in their labels (like + // Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no + // sections, so it keeps the FAB to create a group. + if (!isBuzz) { + FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_create_title), + modifier = Modifier.size(24.dp), + ) + } } }, ) { padding -> - val myPubkey = accountViewModel.userProfile().pubkeyHex // A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct // Messages, Agent Console) even before anything loads, rather than the generic "empty" text. if (channels.isEmpty() && !isBuzz) { @@ -366,9 +401,11 @@ fun RelayGroupChannelListScreen( // 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. + // Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section + // headers, so it needs no bottom clearance. LazyColumn( modifier = Modifier.padding(padding), - contentPadding = PaddingValues(bottom = FAB_CLEARANCE), + contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE), ) { if (showTorHint) { item(key = "tor-hint") { @@ -382,16 +419,15 @@ fun RelayGroupChannelListScreen( } if (isBuzz) { + // While the membership fetch is still running and nothing has loaded, show a + // "Loading…" line. The old "you're not a member — accept the invite in the browser" + // empty text is gone: the section labels below now each carry a "+" to create a + // channel/forum, so an empty community is a starting point, not a dead end. val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty() - if (noChannelsYet) { - item(key = "buzz-no-channels") { + if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) { + item(key = "buzz-loading") { Text( - text = - if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) { - stringRes(R.string.buzz_import_loading) - } else { - stringRes(R.string.buzz_import_empty_body) - }, + text = stringRes(R.string.buzz_import_loading), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(24.dp), @@ -399,57 +435,61 @@ fun RelayGroupChannelListScreen( } } - // -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu) - if (buzzChatChannels.isNotEmpty()) { + // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB + // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. + // The header always shows so the "+" is available even before any channel loads; + // the collapse toggle is offered only when there's something to collapse. + run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, - onToggle = { toggleSection("channels") }, - ) + onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } } - if (!channelsCollapsed) { + if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( 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, - onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, ) } } } - // -- FORUMS -- - if (buzzForumChannels.isNotEmpty()) { + // -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the + // create flow on a forum channel (threaded posts) instead of a chat one. + run { val forumsCollapsed = "forums" in collapsedSections item(key = "sec-forums") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, - onToggle = { toggleSection("forums") }, - ) + onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } } - if (!forumsCollapsed) { + if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( 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. onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) }, isStarred = groupId.id in starred, - onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, // Forum posts live in a separate thread store, not the chat notes the // activity preview reads — so don't warm a kind-9 sub that returns nothing. showActivityPreview = false, @@ -458,6 +498,38 @@ fun RelayGroupChannelListScreen( } } + // -- ARCHIVED -- Channels the relay has archived (chat + forum), tucked into a + // collapsed tail. Opening one and using the top-bar Unarchive brings it back. + if (buzzArchivedChannels.isNotEmpty()) { + val archivedCollapsed = "archived" in collapsedSections + item(key = "sec-archived") { + RelayGroupSectionHeader( + title = stringRes(R.string.relay_group_section_archived), + collapsed = archivedCollapsed, + onToggle = { toggleSection("archived") }, + ) + } + if (!archivedCollapsed) { + itemsIndexed(buzzArchivedChannels, key = { _, it -> "archived-${it.id}" }) { index, groupId -> + RowHairline(index) + val isForum = buzzTypeOf(groupId) == BUZZ_CHANNEL_TYPE_FORUM + BuzzImportRow( + groupId = groupId, + accountViewModel = accountViewModel, + onOpen = { + if (isForum) { + nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) + } else { + nav.nav(Route.RelayGroup(groupId.id, relay.url)) + } + }, + isStarred = groupId.id in starred, + showActivityPreview = !isForum, + ) + } + } + } + // -- DIRECT MESSAGES -- (this community's private conversations, most recent first) item(key = "sec-dms") { RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) { @@ -488,7 +560,6 @@ fun RelayGroupChannelListScreen( row = row, myPubkey = myPubkey, isHidden = false, - onToggleMessages = { dmVm.removeFromMessages(row) }, accountViewModel = accountViewModel, nav = nav, ) { @@ -520,7 +591,6 @@ fun RelayGroupChannelListScreen( row = row, myPubkey = myPubkey, isHidden = true, - onToggleMessages = { dmVm.addToMessages(row) }, accountViewModel = accountViewModel, nav = nav, ) { @@ -640,27 +710,44 @@ private fun RelayGroupSectionHeader( } } +/** + * The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's + * New-message icon: a primary-tinted Add glyph that creates a new item of that section's type. + */ +@Composable +private fun SectionAddButton( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } +} + /** * 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, 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. + * 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; the Add/Remove-from-Messages + * (hide/unhide) action lives in that chat screen's top-bar overflow, not on this row. * - * [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. + * [isHidden] renders the row faded — a hidden DM is a live conversation the viewer merely parked, so + * it stays openable and reversible from the opened conversation. */ @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) } @@ -689,7 +776,7 @@ private fun BuzzDmInlineRow( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp) + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 10.dp) .alpha(if (isHidden) 0.55f else 1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -718,33 +805,6 @@ 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/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index c71641b669..b2528156c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -97,10 +97,15 @@ fun RelayGroupCreateScreen( relayUrl: String, accountViewModel: AccountViewModel, nav: INav, + // Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true). + isForum: Boolean = false, ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl") - LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) } + LaunchedEffect(relay) { + viewModel.initCreate(accountViewModel, relay) + if (isForum) viewModel.isForum = true + } // A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as // ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation @@ -197,8 +202,15 @@ private fun RelayGroupMetadataScaffold( Scaffold( topBar = { if (viewModel.isNewGroup) { + // The type is fixed by the caller (the community's per-section "+"), so the title names + // it — "New forum" vs "New channel" on Buzz — rather than offering a toggle to change it. CreatingTopBar( - titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title, + titleRes = + when { + !viewModel.isBuzzRelay -> R.string.relay_group_create_title + viewModel.isForum -> R.string.buzz_forum_create_title + else -> R.string.buzz_channel_create_title + }, isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) }, onCancel = nav::popBack, onPost = onSubmit, @@ -410,21 +422,11 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { viewModel.markTouched() } - if (viewModel.isBuzzRelay) { - // Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its - // 9002 handler has no `channel_type` key, so an existing channel cannot be converted. - if (viewModel.isNewGroup) { - LabeledSwitchRow( - label = stringRes(R.string.buzz_channel_flag_forum), - description = stringRes(R.string.buzz_channel_flag_forum_desc), - checked = viewModel.isForum, - ) { - viewModel.isForum = it - viewModel.markTouched() - } - } - return - } + // A Buzz channel's type (chat vs forum) is fixed by the caller — the community's per-section "+" — + // and isn't editable (the relay's 9002 has no `channel_type` key), so there's no toggle here. The + // remaining vanilla NIP-29 flags (invite-only / restricted below) don't apply to Buzz either, so + // stop here for a Buzz relay. + if (viewModel.isBuzzRelay) return LabeledSwitchRow( label = stringRes(R.string.relay_group_flag_invite_only), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index e015e48a7b..33354badb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -34,15 +34,20 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -166,6 +172,37 @@ private fun RelayGroupThreads( ) } }, + actions = { + // The forum's per-item actions, moved off the community-list row into this screen's + // top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle, plus the + // admin-only Archive/Unarchive (so an archived forum can be brought back from here). + // Buzz-only, which every forum channel is. + if (isBuzz) { + var menuOpen by remember { mutableStateOf(false) } + val isAdmin = channel.membershipOf(accountViewModel.userProfile().pubkeyHex) == RelayGroupMembership.ADMIN + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + BuzzPinDropdownItem(channel.groupId) { menuOpen = false } + RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false } + if (isAdmin) { + val archived = channel.isArchived() + DropdownMenuItem( + text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) }, + onClick = { + menuOpen = false + accountViewModel.archiveRelayGroup(channel, !archived) + }, + ) + } + } + } + }, popBack = nav::popBack, ) }, 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 47a19bea25..4d725b781b 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 @@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -117,6 +118,9 @@ fun RelayGroupTopBar( // My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below. val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds .collectAsStateWithLifecycle() + // A Buzz DM is hidden via its own per-viewer 30622 snapshot, not the kind-10009 list, so the + // Messages toggle branches on this for DMs. + val hiddenDms by BuzzDmRegistry.hidden.collectAsStateWithLifecycle() // Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu // callback — leaving a group shouldn't strand the user on the screen of a group they left. val canPop = nav.canPop() @@ -237,7 +241,9 @@ fun RelayGroupTopBar( // Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which // the relay's channel list already surfaces in their own section), so it read as a broken // feature on every chat. Demoted to the overflow, where the frequency of use actually is. - if (!isDm || naddr != null || showMembershipActions) { + // A Buzz DM always gets the overflow too — its hide/unhide (below) is the DM row's old + // action, and it must be reachable even where the membership actions aren't offered. + if (!isDm || naddr != null || showMembershipActions || (isDm && isBuzzRelay)) { IconButton(onClick = { menuOpen = true }) { Icon( symbol = MaterialSymbols.MoreVert, @@ -246,6 +252,33 @@ fun RelayGroupTopBar( ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // Pin/Unpin moved here off the community-list row. A local favorite, so it's offered + // for any Buzz channel/forum regardless of membership; DMs are never pinned. + if (isBuzzRelay && !isDm) { + BuzzPinDropdownItem(channel.groupId) { menuOpen = false } + } + // A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer + // 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is + // shown regardless of the membership gate below. + if (isBuzzRelay && isDm) { + val dmHidden = channel.groupId.id in (hiddenDms[myPubkey] ?: emptySet()) + DropdownMenuItem( + text = { Text(stringRes(if (dmHidden) R.string.add_to_messages else R.string.remove_from_messages)) }, + onClick = { + menuOpen = false + if (dmHidden) { + val participants = + channel.event + ?.buzzParticipants() + ?.filter { it != myPubkey } + .orEmpty() + accountViewModel.unhideBuzzDm(channel.groupId.relayUrl, participants.ifEmpty { listOf(myPubkey) }) + } else { + accountViewModel.hideBuzzDm(channel) + } + }, + ) + } if (!isDm) { DropdownMenuItem( text = { Text(stringRes(R.string.relay_group_threads_title)) }, @@ -308,6 +341,8 @@ fun RelayGroupTopBar( // 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. + // (A DM's Messages toggle is the hide/unhide item above — it rides a different + // mechanism and must show even when these membership actions don't.) // // 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 @@ -335,6 +370,19 @@ fun RelayGroupTopBar( if (canPop) nav.popBack() }, ) + // Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar, + // Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog. + // A DM is never archived (it has its own hide), so this is channels/forums only. + if (isBuzzRelay && !isDm && displayMembership == RelayGroupMembership.ADMIN) { + val archived = channel.isArchived() + DropdownMenuItem( + text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) }, + onClick = { + menuOpen = false + accountViewModel.archiveRelayGroup(channel, !archived) + }, + ) + } // Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's // shown ONLY to an admin/owner — the same authorization gate as Edit above — and // routed through a confirmation dialog rather than firing on tap. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 53913183ce..6228800866 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2574,6 +2574,7 @@ Create a group New channel + New forum Private channel Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it. Forum channel @@ -2616,6 +2617,8 @@ Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone. Delete channel Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone. + Archive channel + Unarchive channel Threads Pin message Unpin message @@ -3607,6 +3610,7 @@ Channels Forums + Archived Working… Add member Add people to this workspace diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 11c5bb23bd..9c029296f8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -178,6 +178,9 @@ class RelayGroupChannel( fun isPrivate(): Boolean = event?.isPrivate() ?: false + /** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */ + fun isArchived(): Boolean = event?.isArchived() ?: false + fun isRestricted(): Boolean = event?.isRestricted() ?: false fun isClosed(): Boolean = event?.isClosed() ?: false diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt new file mode 100644 index 0000000000..fc3df75e65 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt @@ -0,0 +1,75 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The set of NIP-29 relay-group channels this device has **deleted** (kind-9008), keyed by + * [GroupId.toKey] (`id@relay`, so a group is scoped to its host relay — group ids are only + * unique per relay). + * + * A delete is terminal and destroys the group for everyone: the relay drops it and stops serving + * its kind-39000 metadata. But the client already holds that metadata in `LocalCache`, and a Buzz + * relay may keep re-announcing a stale kind-44100 member-added notification that would re-surface + * the channel in the community's browse list — after a restart too, since it's re-fetched from the + * relay. So the deletion has to be remembered client-side and the channel filtered out everywhere + * the list is built. + * + * Like [BuzzChannelStars], there is no personal Nostr event for "I deleted this from my view", so + * this is a process-wide singleton mirrored to a device-global store by the platform + * ([com.vitorpamplona.amethyst] `RelayGroupDeletionPreferences`) and restored at startup. Deleting + * is authoritative and terminal, so an entry is only ever added, never removed. + */ +object RelayGroupDeletions { + private val deleted = MutableStateFlow>(emptySet()) + + /** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */ + val flow: StateFlow> = deleted + + fun isDeleted(groupKey: String): Boolean = groupKey in deleted.value + + fun isDeleted(groupId: GroupId): Boolean = isDeleted(groupId.toKey()) + + /** Record [groupId] as deleted (idempotent). */ + fun markDeleted(groupId: GroupId) = markDeleted(groupId.toKey()) + + /** Record [groupKey] ([GroupId.toKey]) as deleted (idempotent). */ + fun markDeleted(groupKey: String) { + while (true) { + val current = deleted.value + if (groupKey in current) return + if (deleted.compareAndSet(current, current + groupKey)) return + } + } + + /** Replaces the whole set — used to restore from disk at startup. */ + fun restore(keys: Set) { + deleted.value = keys + } + + /** Test-only: clears the set so unit tests don't leak state into each other. */ + fun clearForTesting() { + deleted.value = emptySet() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt new file mode 100644 index 0000000000..6a60adc6e1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt @@ -0,0 +1,87 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The device-global "deleted channels" bookkeeping: a delete is relay-scoped (keyed by + * [GroupId.toKey]) and terminal (only ever added), so the same id on a different relay stays visible. + */ +class RelayGroupDeletionsTest { + private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com") + private val gid = "0123456789abcdef" + + @BeforeTest + fun reset() = RelayGroupDeletions.clearForTesting() + + @AfterTest + fun tearDown() = RelayGroupDeletions.clearForTesting() + + @Test + fun marksAChannelDeletedAndReflectsInTheFlow() { + val group = GroupId(gid, relayA) + assertFalse(RelayGroupDeletions.isDeleted(group)) + + RelayGroupDeletions.markDeleted(group) + + assertTrue(RelayGroupDeletions.isDeleted(group)) + assertTrue(RelayGroupDeletions.isDeleted(group.toKey())) + assertEquals(setOf(group.toKey()), RelayGroupDeletions.flow.value) + } + + @Test + fun deletionIsRelayScoped() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + // The same group id on a different host relay is a different group, so it stays visible. + assertTrue(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayB))) + } + + @Test + fun markingIsIdempotent() { + val group = GroupId(gid, relayA) + RelayGroupDeletions.markDeleted(group) + RelayGroupDeletions.markDeleted(group) + + assertEquals(1, RelayGroupDeletions.flow.value.size) + } + + @Test + fun restoreReplacesTheWholeSet() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + val restored = setOf(GroupId("aaaa", relayB).toKey(), GroupId("bbbb", relayB).toKey()) + RelayGroupDeletions.restore(restored) + + assertEquals(restored, RelayGroupDeletions.flow.value) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index a05f2d7b9f..aeb4721570 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -57,6 +57,14 @@ class GroupMetadataEvent( fun picture() = tags.firstTagValue("picture") + /** + * Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar + * state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an + * `["archived","true"]` tag onto the 39000 for an archived channel and clients hide it; a plain + * NIP-29 relay has no such concept, so this is false there. + */ + fun isArchived() = tags.firstTagValue("archived") == "true" + /** * Topic hashtags (`t` tags) the relay advertises for this group, used by the * discovery feed's hashtag filter. NIP-29 doesn't define these; a group only diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index b503c830d3..61ccd41f3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -82,6 +82,12 @@ class EditMetadataEvent( parent: String? = null, children: List = emptyList(), previousEvents: List = emptyList(), + // Buzz-only channel-settings tags (a Buzz relay reads these on 9002; a plain NIP-29 relay + // ignores them). [visibility] is "open"/"private" — Buzz's own vocabulary, which it reads + // instead of the NIP-29 `private` status flag, so editing visibility on a Buzz channel needs + // this tag to take. [archived] toggles the channel's archived state ("true"/"false"). + visibility: String? = null, + archived: Boolean? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -90,6 +96,8 @@ class EditMetadataEvent( about?.let { add(arrayOf("about", it)) } picture?.let { add(arrayOf("picture", it)) } status.forEach { add(arrayOf(it.code)) } + visibility?.let { add(arrayOf("visibility", it)) } + archived?.let { add(arrayOf("archived", if (it) "true" else "false")) } addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt new file mode 100644 index 0000000000..080e477790 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Buzz-only channel-settings tags on kind-9002 edit-metadata (`visibility`, `archived`) and the + * `archived` reflection on the relay-signed kind-39000. These ride the 9002 as tags — Buzz reads its + * own `visibility` vocabulary rather than the NIP-29 `private` status flag, and stamps `archived` onto + * the 39000 for an archived channel. + */ +class ChannelSettingsTagTest { + private val relaySelf = "aa".repeat(32) + private val sig = "bb".repeat(64) + private val id = "00".repeat(32) + private val gid = "0123456789abcdef" + + private fun tagValue( + tags: Array>, + name: String, + ): String? = tags.firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1) + + @Test + fun editEmitsVisibilityTagOnlyWhenSet() { + val priv = EditMetadataEvent.build(gid, visibility = "private") + assertEquals("private", tagValue(priv.tags, "visibility")) + + val open = EditMetadataEvent.build(gid, visibility = "open") + assertEquals("open", tagValue(open.tags, "visibility")) + + // Absent by default so an ordinary metadata edit doesn't reclassify visibility. + assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "visibility")) + } + + @Test + fun editEmitsArchivedTagAsTrueFalse() { + assertEquals("true", tagValue(EditMetadataEvent.build(gid, archived = true).tags, "archived")) + assertEquals("false", tagValue(EditMetadataEvent.build(gid, archived = false).tags, "archived")) + // Null archived means "don't touch it" — no tag emitted. + assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "archived")) + } + + @Test + fun metadataReflectsArchivedFlag() { + val archivedTemplate = GroupMetadataEvent.build(gid, name = gid) { add(arrayOf("archived", "true")) } + val archived = EventFactory.create(id, relaySelf, archivedTemplate.createdAt, GroupMetadataEvent.KIND, archivedTemplate.tags, "", sig) as GroupMetadataEvent + assertTrue(archived.isArchived()) + + val plainTemplate = GroupMetadataEvent.build(gid, name = gid) + val plain = EventFactory.create(id, relaySelf, plainTemplate.createdAt, GroupMetadataEvent.KIND, plainTemplate.tags, "", sig) as GroupMetadataEvent + assertFalse(plain.isArchived()) + } +}