From 1e5d062e32c3c8fca346cf23068d66470b66ca82 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:51:55 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 30 +++ .../RelaySubscriptionsCoordinator.kt | 7 + .../amethyst/ui/navigation/AppNavigation.kt | 19 ++ .../concord/ConcordChannelListScreen.kt | 104 +++++++++ .../concord/ConcordChannelScreen.kt | 208 ++++++++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1447738c99..b2bf83389e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 0d63313e06..027fedeba5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0be3941fad..97841bc732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { + ConcordChannelScreen( + communityId = it.communityId, + channelId = it.channelId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordChannelListScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { RelayGroupMembersScreen( id = it.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt new file mode 100644 index 0000000000..a8e197b529 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt new file mode 100644 index 0000000000..d6febafc96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -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, + ) + } + } + } +}