mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat(buzz): workspace→channels shell, forum composer, attestation persistence
- Workspace shell: BuzzWorkspacesScreen now groups joined Buzz-dialect groups BY RELAY into workspace→channels (a Buzz workspace IS a relay/tenant, per buzz-core relay_url_authority), each an expandable section with its channels and a + to the relay's directory (Concord-style community→channels). - Forum composer (45001): the Threads-tab FAB opens BuzzForumPostScreen on a Buzz relay, publishing a ForumPostEvent (mirrors build_forum_post) instead of the vanilla kind-11 thread a NIP-29 relay uses. - Held-attestation persistence: BuzzAttestationPreferences mirrors BuzzHeldAttestations to the device DataStore and reloads at startup, re-verifying each credential against its agent key (drops tampered entries). Deliberately NOT built: job/huddle composers — jobs (43xxx) and huddles (48xxx) have no builder in buzz-sdk (reserved kinds), so a composer would encode an unconfirmed schema. Presence (20001) skipped per request (EventFactory collision with GeohashPresenceEvent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
|
||||
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.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
@@ -270,6 +271,10 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
|
||||
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
|
||||
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Service that will run at all times to receive events from Pokey
|
||||
val pokeyReceiver = PokeyReceiver()
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
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 kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the NIP-OA attestations this device holds
|
||||
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
|
||||
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
|
||||
* [NamecoinSharedPreferences] (not per-account — the store is already keyed by the agent
|
||||
* pubkey each attestation authorizes).
|
||||
*
|
||||
* On construction it loads the saved entries into the singleton — **re-verifying each
|
||||
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
|
||||
* — then mirrors every later change back to disk. Construct once, eagerly, at startup.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzAttestationPreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Serializable
|
||||
private data class Entry(
|
||||
val agent: HexKey,
|
||||
val owner: HexKey,
|
||||
val conditions: String,
|
||||
val sig: HexKey,
|
||||
)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// Persist on every change AFTER the initial restore (drop(1) skips the value
|
||||
// present at collection start, which restoreFromDisk already wrote).
|
||||
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
val verified =
|
||||
json
|
||||
.decodeFromString<List<Entry>>(raw)
|
||||
.mapNotNull { e ->
|
||||
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
|
||||
// Only reinstate a credential that still verifies for its agent key.
|
||||
if (attestation.verify(e.agent)) e.agent to attestation else null
|
||||
}.toMap()
|
||||
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
|
||||
try {
|
||||
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
|
||||
context.sharedPreferencesDataStore.edit { prefs ->
|
||||
prefs[KEY] = json.encodeToString(list)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringPreferencesKey("buzz.heldAttestations")
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
|
||||
@@ -762,6 +763,7 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.BuzzForumPost> { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
|
||||
@@ -703,6 +703,11 @@ sealed class Route {
|
||||
val channelId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BuzzForumPost(
|
||||
val channelId: String,
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupCreate(
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
+2
-2
@@ -115,8 +115,8 @@ fun AgentAttestationScreen(
|
||||
/**
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
|
||||
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
|
||||
* now — re-paste after an app restart.
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. Persisted across
|
||||
* restarts (device-global) by `BuzzAttestationPreferences`.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldAttestationSection(myPubkey: String) {
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.forum.ForumPostEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Composer for a Buzz **forum post** (NIP kind 45001) — the root of a forum thread in a
|
||||
* workspace channel. The post body is the event content (Buzz forum posts carry no
|
||||
* separate title). Published to the channel's host relay via `build_forum_post`'s Quartz
|
||||
* equivalent ([ForumPostEvent.build]). Reached from the Threads tab's compose FAB on a
|
||||
* Buzz-dialect relay.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzForumPostScreen(
|
||||
channelId: String,
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var body by remember { mutableStateOf("") }
|
||||
var isPosting by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_forum_new_title), nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = body,
|
||||
onValueChange = {
|
||||
body = it
|
||||
error = null
|
||||
},
|
||||
label = { Text(stringRes(R.string.buzz_forum_body_label)) },
|
||||
minLines = 5,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isPosting,
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
|
||||
if (relay == null) {
|
||||
error = "Invalid relay url"
|
||||
return@Button
|
||||
}
|
||||
isPosting = true
|
||||
error = null
|
||||
// Publish on IO, then pop back on the composition's main scope.
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
|
||||
ForumPostEvent.build(channelId, body.trim()),
|
||||
) { listOf(relay) }
|
||||
}
|
||||
nav.popBack()
|
||||
} catch (e: Exception) {
|
||||
isPosting = false
|
||||
error = "Failed to post: ${e.message ?: e::class.simpleName}"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = body.isNotBlank() && !isPosting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isPosting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(stringRes(R.string.buzz_forum_post_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-5
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
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
|
||||
@@ -35,6 +36,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
@@ -42,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -67,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
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.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@@ -90,6 +94,9 @@ fun BuzzWorkspacesScreen(
|
||||
.collectAsStateWithLifecycle()
|
||||
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// A Buzz workspace IS a relay (a tenant, per buzz-core's `relay_url_authority`), and its
|
||||
// channels are the NIP-29 groups on it — so group the joined Buzz-dialect groups by relay
|
||||
// into workspace → channels, the Concord community→channels shape.
|
||||
val workspaces =
|
||||
remember(joined, buzzRelays) {
|
||||
joined
|
||||
@@ -97,9 +104,15 @@ fun BuzzWorkspacesScreen(
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
|
||||
if (relay !in buzzRelays) return@mapNotNull null
|
||||
GroupId(tag.groupId, relay)
|
||||
}.sortedBy { it.id }
|
||||
}.groupBy { it.relayUrl }
|
||||
.toList()
|
||||
.sortedBy { it.first.url }
|
||||
.map { (relay, channels) -> relay to channels.sortedBy { it.id } }
|
||||
}
|
||||
|
||||
// Which workspaces are expanded (channels shown). Defaults to all-open the first time.
|
||||
val expanded = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
@@ -137,8 +150,23 @@ fun BuzzWorkspacesScreen(
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
|
||||
WorkspaceRow(groupId, accountViewModel, nav)
|
||||
workspaces.forEach { (relay, channels) ->
|
||||
val relayKey = relay.url
|
||||
val isOpen = expanded[relayKey] ?: true
|
||||
item(key = "ws-$relayKey") {
|
||||
WorkspaceHeader(
|
||||
relay = relay,
|
||||
channelCount = channels.size,
|
||||
expanded = isOpen,
|
||||
onToggle = { expanded[relayKey] = !isOpen },
|
||||
onBrowse = { nav.nav(Route.RelayGroupServer(relayKey)) },
|
||||
)
|
||||
}
|
||||
if (isOpen) {
|
||||
items(channels, key = { "ch-${it.relayUrl.url}-${it.id}" }) { groupId ->
|
||||
ChannelRow(groupId, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,9 +224,66 @@ private fun AgentConsoleHeroCard(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
|
||||
/**
|
||||
* A workspace header (one per Buzz relay): its host, a channel count, an expand chevron to
|
||||
* fold its channels, and a "browse all" affordance to the relay's full directory. Tapping
|
||||
* the row toggles; the folder icon opens the relay's channel directory.
|
||||
*/
|
||||
@Composable
|
||||
private fun WorkspaceRow(
|
||||
private fun WorkspaceHeader(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelCount: Int,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onBrowse: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = relay.displayUrl(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "$channelCount",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(R.string.buzz_workspaces_browse),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onBrowse)
|
||||
.padding(4.dp)
|
||||
.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One channel (a NIP-29 group): a colored monogram avatar, live name + host, member count, unread dot. */
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
groupId: GroupId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
|
||||
+14
-6
@@ -55,6 +55,7 @@ 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.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -148,12 +149,19 @@ private fun RelayGroupThreads(
|
||||
if (canPost) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
nav.nav(
|
||||
Route.NewShortNote(
|
||||
groupThreadId = channel.groupId.id,
|
||||
groupThreadRelayUrl = channel.groupId.relayUrl.url,
|
||||
),
|
||||
)
|
||||
// On a Buzz workspace, "new thread" is a Buzz forum post (kind 45001);
|
||||
// vanilla NIP-29 relays use a kind-11 thread. Buzz relays reject
|
||||
// unknown kinds, so a kind-11 thread would be refused there.
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
nav.nav(Route.BuzzForumPost(channel.groupId.id, channel.groupId.relayUrl.url))
|
||||
} else {
|
||||
nav.nav(
|
||||
Route.NewShortNote(
|
||||
groupThreadId = channel.groupId.id,
|
||||
groupThreadRelayUrl = channel.groupId.relayUrl.url,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
) {
|
||||
|
||||
@@ -3306,6 +3306,9 @@
|
||||
<string name="buzz_workspaces_browse">Browse groups</string>
|
||||
<string name="buzz_console_card_title">Agent Console</string>
|
||||
<string name="buzz_console_card_subtitle">Costs · Personas · Live observer</string>
|
||||
<string name="buzz_forum_new_title">New forum topic</string>
|
||||
<string name="buzz_forum_body_label">What do you want to discuss?</string>
|
||||
<string name="buzz_forum_post_action">Post topic</string>
|
||||
|
||||
<string name="chat_delivery_details_title">Message Delivery</string>
|
||||
<string name="close">Close</string>
|
||||
|
||||
+9
@@ -84,6 +84,15 @@ object BuzzHeldAttestations {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole store with [entries] — used to restore from disk at startup. The
|
||||
* caller must have re-verified each attestation against its agent key (the same gate
|
||||
* [put] documents), so a tampered on-disk credential can't be reinstated.
|
||||
*/
|
||||
fun restore(entries: Map<HexKey, OwnerAttestation>) {
|
||||
heldByAgent.value = entries
|
||||
}
|
||||
|
||||
/** Test-only: clears all held attestations so unit tests don't leak state. */
|
||||
fun clearForTesting() {
|
||||
heldByAgent.value = emptyMap()
|
||||
|
||||
@@ -198,19 +198,31 @@ live tail requests 20002 (`RELAY_GROUP_OPEN_TAIL_KINDS`, scoped to the room on s
|
||||
Concord's typing feature. (`requires_h_channel_scope(20002)` is false on the relay, but
|
||||
Amethyst still `h`-scopes the send + subscription so it never leaks across channels.)
|
||||
|
||||
**Navigation identity — a "Workspaces" tab.** `NavBarItem.BUZZ` (→ `Route.BuzzWorkspaces`)
|
||||
is a pinnable bottom-nav destination; `amethyst/.../buzz/BuzzWorkspacesScreen` is a hub that
|
||||
filters the joined relay groups to Buzz-dialect relays and leads with an Agent-Console hero
|
||||
card. Buzz workspaces still ALSO appear in the generic Relay Groups list (they are NIP-29
|
||||
groups); the tab is a Buzz-branded lens over the same data, not a separate store.
|
||||
**Navigation identity — a "Workspaces" tab with a channel shell.** `NavBarItem.BUZZ`
|
||||
(→ `Route.BuzzWorkspaces`) is a pinnable bottom-nav destination; `BuzzWorkspacesScreen` is a
|
||||
hub that **groups the joined Buzz-dialect groups by relay into workspace → channels** — a
|
||||
Buzz workspace IS a relay (a tenant, per buzz-core's `relay_url_authority`), and its channels
|
||||
are the NIP-29 groups on it. Each workspace is an expandable section (Concord-style) with its
|
||||
channels; a `+` opens the relay's full channel directory (`Route.RelayGroupServer`). Leads
|
||||
with an Agent-Console hero card. The groups still ALSO appear in the generic Relay Groups
|
||||
list; the tab is a Buzz-branded lens over the same store.
|
||||
|
||||
Still not wired: the forum/job/huddle/DM message composers — each a send flow for a
|
||||
richer kind, and jobs/huddles are among the schema-inferred kinds, so they want Buzz-side
|
||||
confirmation before UI. **Presence (kind 20001)** is accepted by Buzz but collides with
|
||||
Amethyst's `GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash
|
||||
presence), so it needs disambiguation before it can render — unlike typing (20002), which
|
||||
has no such collision. A **workspace→channels shell** (Concord-style channel sidebar) and
|
||||
per-account persistence of held attestations remain follow-ups.
|
||||
**Forum posts (kind 45001) are composable.** On a Buzz relay the Threads-tab FAB opens
|
||||
`BuzzForumPostScreen` (`Route.BuzzForumPost`), which publishes a `ForumPostEvent` (mirroring
|
||||
`build_forum_post`) to the channel's host relay, instead of the vanilla kind-11 thread a
|
||||
NIP-29 relay uses. (Forum posts already render in the chat feed; a dedicated forum reader in
|
||||
the Threads tab is the read-side follow-up.)
|
||||
|
||||
**Held attestations persist** across restarts: `amethyst/.../model/preferences/BuzzAttestationPreferences`
|
||||
mirrors `BuzzHeldAttestations` to the device-global DataStore and reloads it at startup,
|
||||
**re-verifying each against its agent key** so a tampered on-disk credential is dropped.
|
||||
|
||||
Still not wired: **presence (kind 20001)** — accepted by Buzz but it collides with Amethyst's
|
||||
`GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash presence), so it needs
|
||||
disambiguation before it can render (typing/20002 has no such collision). The **job/huddle/DM
|
||||
composers** are deferred: jobs (43xxx) and huddles (48xxx) have **no builder** in `buzz-sdk`
|
||||
(reserved kinds), so a composer would encode an unconfirmed schema; DM (`build_dm_open`, 41001)
|
||||
is a full encrypted-DM subsystem for a later pass.
|
||||
|
||||
## Owner Attestation (NIP-OA) — implemented
|
||||
|
||||
|
||||
Reference in New Issue
Block a user