mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3812 from vitorpamplona/claude/buzz-channel-delete-persist-e3lex3
NIP-29: Channel deletion, archival, and Buzz community UI
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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<String> = 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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
+77
@@ -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<String>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -790,6 +790,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
relayUrl = it.relayUrl,
|
||||
isForum = it.isForum,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+25
@@ -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<HexKey>,
|
||||
) = 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.
|
||||
|
||||
+4
-90
@@ -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<String>,
|
||||
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(
|
||||
|
||||
+34
-5
@@ -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
|
||||
|
||||
+104
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+154
-94
@@ -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<String>()
|
||||
(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<GroupId> { 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<GroupId> { 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<String>()) }
|
||||
// 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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-17
@@ -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),
|
||||
|
||||
+37
@@ -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,
|
||||
)
|
||||
},
|
||||
|
||||
+49
-1
@@ -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.
|
||||
|
||||
@@ -2574,6 +2574,7 @@
|
||||
<string name="relay_group_create_title">Create a group</string>
|
||||
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
|
||||
<string name="buzz_channel_create_title">New channel</string>
|
||||
<string name="buzz_forum_create_title">New forum</string>
|
||||
<string name="buzz_channel_flag_private">Private channel</string>
|
||||
<string name="buzz_channel_flag_private_desc">Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it.</string>
|
||||
<string name="buzz_channel_flag_forum">Forum channel</string>
|
||||
@@ -2616,6 +2617,8 @@
|
||||
<string name="relay_group_delete_confirm">Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone.</string>
|
||||
<string name="buzz_channel_delete">Delete channel</string>
|
||||
<string name="buzz_channel_delete_confirm">Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone.</string>
|
||||
<string name="buzz_channel_archive">Archive channel</string>
|
||||
<string name="buzz_channel_unarchive">Unarchive channel</string>
|
||||
<string name="relay_group_threads_title">Threads</string>
|
||||
<string name="relay_group_pin_message">Pin message</string>
|
||||
<string name="relay_group_unpin_message">Unpin message</string>
|
||||
@@ -3607,6 +3610,7 @@
|
||||
|
||||
<string name="relay_group_section_channels">Channels</string>
|
||||
<string name="relay_group_section_forums">Forums</string>
|
||||
<string name="relay_group_section_archived">Archived</string>
|
||||
<string name="buzz_agent_working">Working…</string>
|
||||
<string name="relay_group_add_member">Add member</string>
|
||||
<string name="buzz_community_add_people">Add people to this workspace</string>
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+75
@@ -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<Set<String>>(emptySet())
|
||||
|
||||
/** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */
|
||||
val flow: StateFlow<Set<String>> = 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<String>) {
|
||||
deleted.value = keys
|
||||
}
|
||||
|
||||
/** Test-only: clears the set so unit tests don't leak state into each other. */
|
||||
fun clearForTesting() {
|
||||
deleted.value = emptySet()
|
||||
}
|
||||
}
|
||||
+87
@@ -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)))
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
|
||||
+8
@@ -82,6 +82,12 @@ class EditMetadataEvent(
|
||||
parent: String? = null,
|
||||
children: List<String> = emptyList(),
|
||||
previousEvents: List<String> = 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<EditMetadataEvent>.() -> 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()) }
|
||||
|
||||
+79
@@ -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<Array<String>>,
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user