From 7ae96ae3ce4ce6256009778942fcd7d784e494e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 03:18:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(buzz):=20presence=20dots,=20collapsible=20?= =?UTF-8?q?sections=20+=20unread=20+=20stars,=20canvas=20editing,=20bot=20?= =?UTF-8?q?"Working=E2=80=A6"=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 + .../preferences/BuzzChannelStarPreferences.kt | 76 ++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../amethyst/ui/navigation/routes/Routes.kt | 1 + .../screen/loggedIn/buzz/BuzzCanvasScreen.kt | 131 +++++++++++++++++- .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 55 ++++++-- .../ui/screen/loggedIn/buzz/BuzzPresence.kt | 78 +++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 109 +++++++++++---- .../relayGroup/RelayGroupMembersScreen.kt | 78 ++++++++++- .../relayGroup/RelayGroupTopBar.kt | 24 ++-- amethyst/src/main/res/values/strings.xml | 6 + .../model/buzz/BuzzAgentActivityState.kt | 65 +++++++++ .../commons/model/buzz/BuzzChannelStars.kt | 60 ++++++++ 13 files changed, 631 insertions(+), 58 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzPresence.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 3f05c8122d..707b98219d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt new file mode 100644 index 0000000000..ba694304b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt @@ -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) { + 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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a26ce3108f..6811206223 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -768,7 +768,7 @@ fun BuildNavigation( nav = nav, ) } - composableFromEndArgs { BuzzCanvasScreen(it.channelId, accountViewModel, nav) } + composableFromEndArgs { BuzzCanvasScreen(it.channelId, it.relayUrl, accountViewModel, nav) } composableFromBottomArgs { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) } composableFromEndArgs { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f17f3aac53..9401c1916d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -704,6 +704,7 @@ sealed class Route { @Serializable data class BuzzCanvas( val channelId: String, + val relayUrl: String, ) : Route() @Serializable data class BuzzForumPost( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt index 72253f6e2b..91c8cd45bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt @@ -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(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) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index bba0d6caaa..f44ba08cde 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzPresence.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzPresence.kt new file mode 100644 index 0000000000..1958d7a489 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzPresence.kt @@ -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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 149196283a..c6303d99ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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()) } + + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 48c6ac5cdc..0da1e79370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -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().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. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 4a75120a83..763cd74f2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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), + ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 00d8f01124..ba0d9b5186 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3305,6 +3305,9 @@ (diff truncated) Canvas No canvas has been shared in this workspace yet. + Edit canvas + Save canvas + Canvas (Markdown) Edit Editing message %1$s is typing… @@ -3362,6 +3365,9 @@ Channels Forums + Working… + Star channel + Unstar channel No conversations yet See %1$d more conversation diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt new file mode 100644 index 0000000000..a394d730b9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzAgentActivityState.kt @@ -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() + private val mutableActivity = MutableStateFlow>(emptyMap()) + + /** `agentPubKey -> newest observer-frame time (secs)`; the UI collects this and checks freshness. */ + val flow: StateFlow> = 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() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt new file mode 100644 index 0000000000..c5237ed38c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzChannelStars.kt @@ -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>(emptySet()) + + /** The starred channel ids; the community view collects this to pin + badge them. */ + val flow: StateFlow> = 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) { + starred.value = ids + } + + /** Test-only: clears the set so unit tests don't leak state into each other. */ + fun clearForTesting() { + starred.value = emptySet() + } +}