feat(buzz): presence dots, collapsible sections + unread + stars, canvas editing, bot "Working…" indicator

Brings the Buzz community + member screens closer to Buzz's own clients:

- Presence dots (BuzzPresenceState → online green / away amber) overlaid
  on DM-row and member-list avatars; offline/unknown shows nothing rather
  than implying we track a peer we don't.
- Community view sections (Channels / Forums) are now collapsible, each
  channel row carries an unread dot (relayGroupChannelHasUnreadFlow) and a
  star toggle; starred channels float to the top. Stars persist
  device-globally (BuzzChannelStars + BuzzChannelStarPreferences), mirroring
  the joined-workspaces store.
- Canvas editing: the canvas screen gains an edit mode that publishes a
  fresh kind-40100 CanvasEvent to the channel's host relay (last-write-wins);
  the top-bar canvas button now shows on any Buzz channel so an empty one can
  be created.
- Bot "Working…" indicator: a process-wide BuzzAgentActivityState, fed by a
  members-screen observer (24200) subscription, lights a live "Working…" line
  next to an agent currently emitting frames; tapping opens the community's
  Agent Console. Owner-scoped by nature (frames are #p=owner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-23 03:28:48 +00:00
parent bb88a18474
commit 7ae96ae3ce
13 changed files with 631 additions and 58 deletions
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
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
@@ -281,6 +282,9 @@ class AppModules(
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -0,0 +1,76 @@
/*
* 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.buzz.BuzzChannelStars
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 starred Buzz workspace channels ([BuzzChannelStars]),
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
* loads the saved ids into the singleton on construction, then writes every later change back.
* Construct once, eagerly.
*/
@Stable
class BuzzChannelStarPreferences(
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.
BuzzChannelStars.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
}
}
private suspend fun persist(ids: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
}
}
@@ -768,7 +768,7 @@ fun BuildNavigation(
nav = nav,
)
}
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromBottomArgs<Route.BuzzForumPost> { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzForumThread> { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) }
@@ -704,6 +704,7 @@ sealed class Route {
@Serializable data class BuzzCanvas(
val channelId: String,
val relayUrl: String,
) : Route()
@Serializable data class BuzzForumPost(
@@ -20,18 +20,28 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -39,6 +49,8 @@ import androidx.compose.ui.text.style.TextAlign
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.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -46,17 +58,26 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Renders a Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown
* document for a channel. The canvas is overlay state held in [BuzzWorkspaceStates]
* (never a timeline row a workspace has one live canvas, last-write-wins), so this
* reads the registry by the channel's `h` id and re-composes off `canvasUpdates` when a
* newer revision lands.
* A Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown document for a channel.
* The canvas is overlay state held in [BuzzWorkspaceStates] (never a timeline row a workspace has
* one live canvas, last-write-wins), so this reads the registry by the channel's `h` id and
* re-composes off `canvasUpdates` when a newer revision lands.
*
* The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the
* channel's host [relayUrl] (last-write-wins on the relay too), and the consume path folds the new
* revision back into [BuzzWorkspaceStates] so the view updates without a manual refresh.
*/
@Composable
fun BuzzCanvasScreen(
channelId: String,
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -66,8 +87,26 @@ fun BuzzCanvasScreen(
val canvas = remember(version) { state.canvasNote }
val content = canvas?.event?.content
var editing by remember { mutableStateOf(false) }
if (editing) {
CanvasEditor(
channelId = channelId,
relayUrl = relayUrl,
initial = content.orEmpty(),
accountViewModel = accountViewModel,
onClose = { editing = false },
)
return
}
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
floatingActionButton = {
FloatingActionButton(onClick = { editing = true }) {
Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(R.string.buzz_canvas_edit))
}
},
) { padding ->
if (content.isNullOrBlank() || canvas == null) {
Box(
@@ -109,3 +148,85 @@ fun BuzzCanvasScreen(
}
}
}
/** The markdown editor: a full-height text field for the canvas body with a Save action. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CanvasEditor(
channelId: String,
relayUrl: String,
initial: String,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
) {
var text by remember { mutableStateOf(initial) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// Back cancels the edit and returns to the rendered canvas rather than leaving the screen.
BackHandler(enabled = !saving, onBack = onClose)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.buzz_canvas_edit)) },
navigationIcon = {
IconButton(onClick = onClose, enabled = !saving) {
Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.cancel))
}
},
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
val relay =
RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: run {
error = "Invalid relay url"
return@FloatingActionButton
}
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) {
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
CanvasEvent.build(channelId, text),
) { listOf(relay) }
}
onClose()
} catch (e: Exception) {
saving = false
error = "Failed to save: ${e.message ?: e::class.simpleName}"
}
}
},
) {
if (saving) {
CircularProgressIndicator(modifier = Modifier.padding(14.dp), strokeWidth = 2.dp)
} else {
Icon(symbol = MaterialSymbols.Check, contentDescription = stringRes(R.string.buzz_canvas_save))
}
}
},
) { padding ->
Box(Modifier.padding(padding).fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = text,
onValueChange = {
text = it
error = null
},
modifier = Modifier.fillMaxSize(),
enabled = !saving,
label = { Text(stringRes(R.string.buzz_canvas_body_label)) },
)
error?.let {
SelectionContainer(Modifier.align(Alignment.BottomStart)) {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -44,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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
@@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -67,6 +70,8 @@ fun BuzzImportRow(
onAdd: () -> Unit,
accountViewModel: AccountViewModel,
onOpen: (() -> Unit)? = null,
isStarred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
) {
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
val channelState by observeChannel(baseChannel, accountViewModel)
@@ -75,14 +80,19 @@ fun BuzzImportRow(
val name = channel.toBestDisplayName()
val memberCount = channel.memberCount()
// An unread dot when this group has chat newer than the last time this account opened it.
val hasUnread by remember(groupId) {
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
}.collectAsStateWithLifecycle(false)
val content =
@Composable {
BuzzImportRowContent(name, groupId.id, memberCount, isAdded, hasUnread, isStarred, onToggleStar, onAdd)
}
if (onOpen != null) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
BuzzImportRowContent(name, groupId.id, memberCount, isAdded, onAdd)
}
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { content() }
} else {
Card(modifier = Modifier.fillMaxWidth()) {
BuzzImportRowContent(name, groupId.id, memberCount, isAdded, onAdd)
}
Card(modifier = Modifier.fillMaxWidth()) { content() }
}
}
@@ -92,19 +102,36 @@ private fun BuzzImportRowContent(
seed: String,
memberCount: Int,
isAdded: Boolean,
hasUnread: Boolean,
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
onAdd: () -> Unit,
) {
Row(
modifier = Modifier.padding(12.dp),
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BuzzImportAvatar(name = name, seed = seed)
Box {
BuzzImportAvatar(name = name, seed = seed)
if (hasUnread) {
Box(
Modifier
.align(Alignment.TopEnd)
.size(11.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(2.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -116,6 +143,16 @@ private fun BuzzImportRowContent(
)
}
}
if (onToggleStar != null) {
IconButton(onClick = onToggleStar) {
Icon(
symbol = if (isStarred) MaterialSymbols.Star else MaterialSymbols.StarBorder,
contentDescription = stringRes(if (isStarred) R.string.buzz_unstar else R.string.buzz_star),
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
if (isAdded) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
@@ -0,0 +1,78 @@
/*
* 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.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
import com.vitorpamplona.quartz.buzz.presence.PresenceStatus
import com.vitorpamplona.quartz.nip01Core.core.HexKey
private val PresenceOnline = Color(0xFF4CAF50)
private val PresenceAway = Color(0xFFFFB300)
/** The latest Buzz presence for [pubkey], mapped to the curated enum, or null when none is known. */
@Composable
fun rememberBuzzPresence(pubkey: HexKey): PresenceStatus? {
val map by BuzzPresenceState.flow.collectAsStateWithLifecycle()
return remember(map, pubkey) { map[pubkey]?.let { PresenceStatus.fromWire(it) } }
}
/**
* A small status dot for a Buzz workspace member green online, amber away rendered nowhere for
* offline/unknown so the UI doesn't imply presence tracking where there is none. Meant to be
* overlaid on the bottom-end of an avatar inside a [Box] (pass `Modifier.align(...)`).
*/
@Composable
fun PresenceDot(
pubkey: HexKey,
modifier: Modifier = Modifier,
size: Dp = 11.dp,
ringColor: Color = Color.Unspecified,
) {
val color =
when (rememberBuzzPresence(pubkey)) {
PresenceStatus.ONLINE -> PresenceOnline
PresenceStatus.AWAY -> PresenceAway
// Offline / unknown: render nothing rather than a grey dot implying we track them.
else -> return
}
Box(
modifier =
modifier
.size(size)
.clip(CircleShape)
.then(if (ringColor != Color.Unspecified) Modifier.border(2.dp, ringColor, CircleShape) else Modifier)
.background(color, CircleShape),
)
}
@@ -54,6 +54,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.Amethyst
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.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.tor.TorType
@@ -83,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
@@ -207,15 +210,29 @@ fun RelayGroupChannelListScreen(
}
fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType()
// Starred channels float to the top of their section (stable sort keeps the alphabetical order
// within the starred and unstarred buckets).
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
val buzzChatChannels =
remember(buzzGroupIds, channelsById) {
buzzGroupIds.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } }
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } }
.sortedByDescending { it.id in starred }
}
val buzzForumChannels =
remember(buzzGroupIds, channelsById) {
buzzGroupIds.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM }
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM }
.sortedByDescending { it.id in starred }
}
// Which sections the user has collapsed (session-scoped). Keyed by section id below.
var collapsedSections by remember { mutableStateOf(emptySet<String>()) }
fun toggleSection(key: String) {
collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key
}
// Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over
// Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded
// after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted
@@ -313,8 +330,13 @@ fun RelayGroupChannelListScreen(
// -- CHANNELS --
if (buzzChatChannels.isNotEmpty()) {
val channelsCollapsed = "channels" in collapsedSections
item(key = "sec-channels") {
RelayGroupSectionHeader(title = stringRes(R.string.relay_group_section_channels)) {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_channels),
collapsed = channelsCollapsed,
onToggle = { toggleSection("channels") },
) {
if (buzzChatChannels.any { it.id !in buzzAdded }) {
FilledTonalButton(onClick = { buzzVm.addAll() }) {
Text(stringRes(R.string.buzz_import_add_all))
@@ -322,33 +344,46 @@ fun RelayGroupChannelListScreen(
}
}
}
items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId ->
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
)
if (!channelsCollapsed) {
items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId ->
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(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()) {
val forumsCollapsed = "forums" in collapsedSections
item(key = "sec-forums") {
RelayGroupSectionHeader(title = stringRes(R.string.relay_group_section_forums))
}
items(buzzForumChannels, key = { "forum-${it.id}" }) { groupId ->
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(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)) },
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_forums),
collapsed = forumsCollapsed,
onToggle = { toggleSection("forums") },
)
}
if (!forumsCollapsed) {
items(buzzForumChannels, key = { "forum-${it.id}" }) { groupId ->
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(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) },
)
}
}
}
// -- DIRECT MESSAGES -- (this community's private conversations, most recent first)
@@ -452,12 +487,28 @@ private fun TorClearnetBanner(
@Composable
private fun RelayGroupSectionHeader(
title: String,
collapsed: Boolean = false,
onToggle: (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 18.dp, bottom = 4.dp),
modifier =
Modifier
.fillMaxWidth()
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
.padding(start = 16.dp, end = 8.dp, top = 18.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (onToggle != null) {
// A single chevron rotated 90° when expanded, so no extra glyph is needed.
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp).rotate(if (collapsed) 0f else 90f),
)
}
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
@@ -497,7 +548,13 @@ private fun BuzzDmInlineRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(leadHex, 40.dp, accountViewModel = accountViewModel, nav = nav)
// Only show a presence dot for a 1:1 DM — a cluster avatar can't carry one peer's status.
Box {
UserPicture(leadHex, 40.dp, accountViewModel = accountViewModel, nav = nav)
if (others.size == 1) {
PresenceDot(leadHex, Modifier.align(Alignment.BottomEnd), ringColor = MaterialTheme.colorScheme.surface)
}
}
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
@@ -43,32 +46,45 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.BuzzAgentActivityState
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.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
/**
* The roster of a NIP-29 group: everyone the relay lists as an admin (kind 39001)
@@ -121,6 +137,21 @@ private fun RelayGroupMembers(
val iCanModerate = channel.membershipOf(myPubkey).canModerate()
val iAmAdmin = channel.membershipOf(myPubkey) == RelayGroupMembership.ADMIN
// Feed the process-wide agent-activity state while this screen is open so a "Working…" line
// can light up next to an agent that's currently running. Observer frames (24200) are
// ephemeral and `#p`-addressed to the owner, so only the owner ever sees them — a non-owner
// simply never records anything and no indicator shows. Buzz relays only.
val relay = channel.groupId.relayUrl
LaunchedEffect(relay, myPubkey) {
if (!BuzzRelayDialect.isBuzz(relay)) return@LaunchedEffect
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
accountViewModel.account.client.subscribeAsFlow(relay, filter).collect { events ->
events.filterIsInstance<ObserverFrameEvent>().forEach { frame ->
BuzzAgentActivityState.record(frame.agentPubKey() ?: frame.pubKey, frame.createdAt)
}
}
}
// Admins/moderators first, then plain members; alphabetical only inside a rank
// is overkill here — rely on relay order but push elevated roles to the top.
val roster =
@@ -216,7 +247,10 @@ private fun RelayGroupMemberRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav)
Box {
UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav)
PresenceDot(entry.pubkey, Modifier.align(Alignment.BottomEnd), ringColor = MaterialTheme.colorScheme.surface)
}
Column(Modifier.weight(1f)) {
if (user != null) {
@@ -229,6 +263,9 @@ private fun RelayGroupMemberRow(
overflow = TextOverflow.Ellipsis,
)
}
// A live "Working…" line for an agent member currently emitting observer frames;
// tapping it opens this community's Agent Console (Observer tab).
BotWorkingLabel(entry.pubkey) { nav.nav(Route.AgentConsole(channel.groupId.relayUrl.url)) }
}
MemberRoleBadge(entry)
@@ -350,6 +387,45 @@ private fun RelayGroupMemberRow(
}
}
/** How recent an observer frame must be for an agent to read as "Working…" right now. */
private const val BOT_WORKING_WINDOW_SECS = 90L
/**
* A live "Working…" line shown for an agent member that emitted an observer frame within the last
* [BOT_WORKING_WINDOW_SECS]. Renders nothing for a normal member (no frames) or a stale agent, and
* re-checks freshness on a slow tick so it fades out when the agent goes quiet.
*/
@Composable
private fun BotWorkingLabel(
agent: HexKey,
onClick: () -> Unit,
) {
val activity by BuzzAgentActivityState.flow.collectAsStateWithLifecycle()
val last = activity[agent] ?: return
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
LaunchedEffect(agent) {
while (true) {
nowSecs = TimeUtils.now()
delay(4_000)
}
}
if (nowSecs - last > BOT_WORKING_WINDOW_SECS) return
Row(
modifier = Modifier.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(6.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary))
Text(
text = stringRes(R.string.buzz_agent_working),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
/**
* A small colored pill for an elevated role; plain members get nothing.
*
@@ -37,7 +37,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,7 +51,6 @@ 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.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
@@ -167,21 +165,15 @@ fun RelayGroupTopBar(
)
}
// Buzz workspace canvas (kind 40100): shown only on a Buzz-dialect relay once
// a canvas has arrived for this channel. Observing canvasUpdates flips it on
// the moment the first canvas is consumed, without swapping the channel object.
// Buzz workspace canvas (kind 40100): shown on any Buzz-dialect relay so a member can
// open the shared markdown doc — or create one when the channel has none yet.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
val canvasState = remember(channel.groupId.id) { BuzzWorkspaceStates.getOrCreate(channel.groupId.id) }
val canvasVersion by canvasState.canvasUpdates.collectAsState()
val hasCanvas = remember(canvasVersion) { canvasState.canvasNote != null }
if (hasCanvas) {
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id)) }) {
Icon(
symbol = MaterialSymbols.Dashboard,
contentDescription = stringRes(R.string.buzz_canvas_title),
modifier = Modifier.size(20.dp),
)
}
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
Icon(
symbol = MaterialSymbols.Dashboard,
contentDescription = stringRes(R.string.buzz_canvas_title),
modifier = Modifier.size(20.dp),
)
}
}
+6
View File
@@ -3305,6 +3305,9 @@
<string name="buzz_diff_truncated">(diff truncated)</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
<string name="buzz_canvas_edit">Edit canvas</string>
<string name="buzz_canvas_save">Save canvas</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_edit_message">Edit</string>
<string name="buzz_editing_banner">Editing message</string>
<string name="buzz_typing_one">%1$s is typing…</string>
@@ -3362,6 +3365,9 @@
<string name="relay_group_section_channels">Channels</string>
<string name="relay_group_section_forums">Forums</string>
<string name="buzz_agent_working">Working…</string>
<string name="buzz_star">Star channel</string>
<string name="buzz_unstar">Unstar channel</string>
<string name="buzz_dm_section_empty">No conversations yet</string>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">See %1$d more conversation</item>
@@ -0,0 +1,65 @@
/*
* 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.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Live "an agent is currently working" state for Buzz workspaces, fed by the ephemeral NIP-AO
* observer frames (kind-24200, `#p` = owner). Frames stream in only while an agent is actively
* running and only reach the owner, so the mere arrival of a recent frame no decryption needed
* is the liveness signal a "Working…" indicator wants.
*
* Shape: `agentPubKey -> the newest frame time (secs)`. The display edge decides how fresh counts
* as "working" (a short window), since ephemeral frames are frequent while active and simply stop
* when the turn ends. Process-wide singleton like [BuzzTypingState] / [BuzzPresenceState]; mutations
* are lock-guarded because relay reads land on several reader threads.
*/
object BuzzAgentActivityState {
private val lock = KmpLock()
private val lastFrameSecs = HashMap<HexKey, Long>()
private val mutableActivity = MutableStateFlow<Map<HexKey, Long>>(emptyMap())
/** `agentPubKey -> newest observer-frame time (secs)`; the UI collects this and checks freshness. */
val flow: StateFlow<Map<HexKey, Long>> = mutableActivity
/** Records that [agent] emitted a frame at [atSecs]; keeps the newest so staleness is monotonic. */
fun record(
agent: HexKey,
atSecs: Long,
) = lock.withLock {
val prev = lastFrameSecs[agent]
if (prev != null && atSecs <= prev) return@withLock
lastFrameSecs[agent] = atSecs
mutableActivity.value = lastFrameSecs.toMap()
}
/** Test-only: clears all activity so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
lastFrameSecs.clear()
mutableActivity.value = emptyMap()
}
}
@@ -0,0 +1,60 @@
/*
* 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.buzz
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The set of Buzz workspace channels the user has **starred** (favorited), keyed by channel id (the
* NIP-29 `h`/UUID, globally unique on Buzz). Starred channels float to the top of the community view.
*
* There is no Nostr event for a personal star it's the client's own bookkeeping so, like
* [BuzzWorkspaces], this is a process-wide singleton mirrored to a device-global store by the
* platform ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
*/
object BuzzChannelStars {
private val starred = MutableStateFlow<Set<String>>(emptySet())
/** The starred channel ids; the community view collects this to pin + badge them. */
val flow: StateFlow<Set<String>> = starred
fun isStarred(channelId: String): Boolean = channelId in starred.value
/** Flips [channelId]'s star. Returns the new state (true = now starred). */
fun toggle(channelId: String): Boolean {
while (true) {
val current = starred.value
val next = if (channelId in current) current - channelId else current + channelId
if (starred.compareAndSet(current, next)) return channelId in next
}
}
/** Replaces the whole set — used to restore from disk at startup. */
fun restore(ids: Set<String>) {
starred.value = ids
}
/** Test-only: clears the set so unit tests don't leak state into each other. */
fun clearForTesting() {
starred.value = emptySet()
}
}