feat(concord): chat + channel-list screens, send path, nav routes

- ConcordChannelScreen: renders a channel's decrypted message flow with a
  composer; posting derives the channel plane key and publishes an encrypted
  wrap to the community relays (Account.sendConcordChannelMessage), with an
  instant local echo via the session fold.
- ConcordChannelListScreen: the community "server" view listing folded channels.
- Registers the ConcordChannelFilterAssembler in RelaySubscriptionsCoordinator
  and wires Route.Concord / Route.ConcordServer into AppNavigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-10 19:51:55 +00:00
parent 285317b0c2
commit 1e5d062e32
5 changed files with 368 additions and 0 deletions
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
@@ -158,6 +159,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
@@ -168,6 +170,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -1488,6 +1491,33 @@ class Account(
/** Drop a joined Concord community from the private kind-13302 list by its id. */
suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId))
/**
* Post [text] to a Concord channel: derive the channel plane key, build an
* encrypted-seal kind-1059 wrap authored by that plane key (not our identity),
* fold it locally for an instant echo, and publish it to the community's relays.
* The `p` tag is ephemeral, so this never routes through the DM outbox — it goes
* straight to the community relay set. Returns false if not writeable or the
* community isn't currently joined/folded.
*/
suspend fun sendConcordChannelMessage(
communityId: String,
channelIdHex: String,
text: String,
): Boolean {
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now())
// Instant local echo, then publish to every relay the community lives on.
concordSessions.ingest(wrap)
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isNotEmpty()) client.publish(wrap, relays)
return true
}
// ── NIP-29 relay-group actions ───────────────────────────────────────────
// All group commands are published ONLY to the group's host relay, where
// relay29 authorizes them. The relay is the source of truth; the kind-10009
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFil
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler
@@ -128,6 +129,11 @@ class RelaySubscriptionsCoordinator(
val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab
val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed
// Concord Channels (encrypted communities). One assembler keeps every joined community's
// control + channel planes live (kind-1059 by derived stream address).
val concordChannels = ConcordChannelFilterAssembler(client)
val chatroom = ChatroomFilterAssembler(client)
val community = CommunityFilterAssembler(client)
val gitRepository = RepositoryFilterAssembler(client)
@@ -194,6 +200,7 @@ class RelaySubscriptionsCoordinator(
relayGroupThreadFeed,
relayGroupWarmup,
relayGroupsDiscovery,
concordChannels,
account,
accountForeground,
home,
@@ -100,6 +100,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGro
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
@@ -586,6 +588,23 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.Concord> {
ConcordChannelScreen(
communityId = it.communityId,
channelId = it.channelId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordServer> {
ConcordChannelListScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupMembers> {
RelayGroupMembersScreen(
id = it.id,
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.clickable
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The channel list of one Concord community (the "server" view). Reads the folded
* Control Plane from the community session and renders one row per channel; tapping
* opens that channel's [ConcordChannelScreen].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordChannelListScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
val account = accountViewModel.account
val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) }
val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) })
.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(state?.metadata?.name ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name), fontWeight = FontWeight.Bold, maxLines = 1) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
},
)
},
) { padding ->
val channels =
state
?.channels
?.entries
?.toList()
.orEmpty()
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(channels, key = { it.key }) { entry ->
val name = entry.value.definition?.name ?: entry.key
Column(
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Text("# $name", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
}
HorizontalDivider()
}
}
}
}
@@ -0,0 +1,208 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The chat screen of one Concord Channel. Messages come from the community
* session's decrypted, ordered flow (not LocalCache notes); posting derives the
* channel plane key and publishes an encrypted wrap to the community's relays.
*
* Mounts [ConcordChannelSubscription] so the channel's plane stays live while the
* screen is foregrounded (and so a just-opened channel re-subscribes once its
* Control Plane folds).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordChannelScreen(
communityId: String,
channelId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
val account = accountViewModel.account
val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) }
val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) }
val messages by (session?.messagesFlow(channelId) ?: remember { MutableStateFlow(emptyList()) })
.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var draft by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(channel.toBestDisplayName(), fontWeight = FontWeight.Bold, maxLines = 1)
channel.communityName?.let {
Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
},
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).imePadding()) {
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(messages.asReversed(), key = { it.id }) { message ->
val mine = message.author == account.signer.pubKey
ConcordMessageBubble(
author = message.author,
content = message.content,
mine = mine,
accountViewModel = accountViewModel,
)
}
}
if (channel.canPost()) {
ConcordComposer(
draft = draft,
onDraftChange = { draft = it },
onSend = {
val text = draft.trim()
if (text.isNotEmpty()) {
draft = ""
scope.launch { account.sendConcordChannelMessage(communityId, channelId, text) }
}
},
)
}
}
}
}
@Composable
private fun ConcordMessageBubble(
author: String,
content: String,
mine: Boolean,
accountViewModel: AccountViewModel,
) {
val user = remember(author) { LocalCache.getOrCreateUser(author) }
val name by observeUserName(user, accountViewModel)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
) {
Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
if (!mine) {
Text(name, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
}
Text(content, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@Composable
private fun ConcordComposer(
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
modifier = Modifier.weight(1f),
placeholder = { Text(stringRes(com.vitorpamplona.amethyst.R.string.reply_here)) },
maxLines = 5,
)
Box(Modifier.padding(start = 6.dp)) {
IconButton(onClick = onSend, enabled = draft.isNotBlank()) {
SymbolIcon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.send),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}