mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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()
|
||||
|
||||
|
||||
+76
@@ -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(
|
||||
|
||||
+126
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-9
@@ -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(
|
||||
|
||||
+78
@@ -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),
|
||||
)
|
||||
}
|
||||
+83
-26
@@ -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,
|
||||
|
||||
+77
-1
@@ -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.
|
||||
*
|
||||
|
||||
+8
-16
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+65
@@ -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()
|
||||
}
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user