From c4657d482eb07422c8ffe14b2c725c1a93f3d936 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:03:10 +0000 Subject: [PATCH] feat(concord): invite card, deep links, and Messages group-by-community MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the parity gaps with NIP-29 relay groups: - Rich invite card (ConcordInviteCard): an invite link in note content now renders as a tappable card that peeks the kind-33301 bundle (Account.peekConcordInvite) to show the community name, instead of a bare link. Wired into RichTextViewer. - Bare naddr (kind 33301) in ClickableRoute now shows an informative label rather than an empty addressable-note card (a naddr has no unlock token, so it can't be joined — only the full link can). - External invite URLs open the app: AndroidManifest intent-filter for amethyst.social/invite/*, and MainActivity.uriToRoute maps the full URL (fragment included) to Route.ConcordInvite so the redeem flow keeps the token. - Messages group-by-community view mode (ConcordViewMode INLINE/GROUPED), the analog of NIP-29's group-by-relay: GROUPED collapses each community's channels into one ConcordServerRoomNote row (rendered by ConcordServerRoomCompose, opens the channel list). Adds updateConcordViewMode + LocalPreferences persistence + feed invalidation + the ChatroomListKnownFeedFilter branch + a Messages-settings toggle. Also resolves a silent merge artifact: main's markDmRoomAsRead(signedEvents.msg) landed in the wraps-only broadcastPrivately overload (no signedEvents in scope); moved it to the Result overload that carries .msg. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- amethyst/src/main/AndroidManifest.xml | 10 ++ .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 7 +- .../amethyst/model/AccountSettings.kt | 7 + .../vitorpamplona/amethyst/ui/MainActivity.kt | 14 ++ .../amethyst/ui/components/ClickableRoute.kt | 15 ++ .../ui/components/ConcordInviteCard.kt | 133 ++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 1 + .../loggedIn/AccountFeedContentStates.kt | 9 ++ .../chats/rooms/ChatroomHeaderCompose.kt | 53 +++++++ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 93 ++++++++---- .../chats/rooms/dal/ConcordServerRoomNote.kt | 48 +++++++ .../settings/MessagesSettingsScreen.kt | 28 +++- amethyst/src/main/res/values/strings.xml | 7 + .../commons/model/concord/ConcordViewMode.kt | 2 + 15 files changed, 399 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index e8035be81f..5a7960c004 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -193,6 +193,16 @@ + + + + + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index aa759eed27..b437a6e838 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -26,6 +26,7 @@ import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm @@ -162,6 +163,7 @@ private object PrefKeys { const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode" + const val CONCORD_VIEW_MODE = "concord_view_mode" const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues" const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows" const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows" @@ -521,6 +523,7 @@ object LocalPreferences { putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name) + putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name) putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value) putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value) putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value) @@ -646,6 +649,7 @@ object LocalPreferences { ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } ?: RelayAuthPolicy.CUSTOM val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)) + val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)) val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true) val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true) val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true) @@ -859,6 +863,7 @@ object LocalPreferences { alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), relayGroupViewMode = MutableStateFlow(relayGroupViewMode), + concordViewMode = MutableStateFlow(concordViewMode), relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays), relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows), relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows), 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 92b733913a..4cdad3b244 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3275,7 +3275,10 @@ class Account( } } - suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) = broadcastPrivately(signedEvents.wraps) + suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) { + broadcastPrivately(signedEvents.wraps) + markDmRoomAsRead(signedEvents.msg) + } suspend fun broadcastPrivately(wraps: List) { val mine = wraps.filter { (it.recipientPubKey() == signer.pubKey) } @@ -3304,8 +3307,6 @@ class Account( // batcher re-delivers this note later; the processor's replay path and // the chatroom add are both idempotent. mineNote?.let { newNotesPreProcessor.consume(it) } - - markDmRoomAsRead(signedEvents.msg) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 5f9799dbdd..70f5e57625 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -310,6 +310,13 @@ class AccountSettings( } } + fun updateConcordViewMode(mode: ConcordViewMode) { + if (concordViewMode.value != mode) { + concordViewMode.tryEmit(mode) + saveAccountSettings() + } + } + // --- // Always-on Notification Service // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 8c6a05081f..d87ea9db23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.model.Account @@ -223,6 +224,7 @@ fun uriToRoute( } relayGroupInviteRoute(uri)?.let { return it } + concordInviteRoute(uri)?.let { return it } val nip19 = Nip19Parser.uriToRoute(uri)?.entity if (nip19 != null) { @@ -355,3 +357,15 @@ private fun relayGroupInviteRoute(uri: String): Route? { val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code) } + +/** + * A shared Concord invite URL (`…/invite/#`). Cheap substring gates + * keep the parse off the hot path; the whole URL (fragment included) is carried into + * the route so the redeem flow still has the unlock token. + */ +private fun concordInviteRoute(uri: String): Route? = + if (uri.contains("/invite/") && uri.contains('#') && ConcordActions.parseInviteLink(uri) != null) { + Route.ConcordInvite(uri) + } else { + null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index e87ede251a..03ba880bb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.model.Note @@ -65,6 +66,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -217,6 +220,18 @@ private fun DisplayAddress( return } + // A Concord invite bundle (kind 33301) is addressed by a bare naddr, but redeeming it + // needs the 16-byte unlock token that only lives in the full invite link's #fragment — + // a naddr alone can't be joined. Show an informative label instead of the generic + // (and here always-empty) addressable-note card. + if (nip19.kind == ConcordKinds.INVITE_BUNDLE) { + Text( + text = stringRes(R.string.concord_invite_naddr_label) + (additionalChars ?: ""), + color = MaterialTheme.colorScheme.primary, + ) + return + } + var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.aTag())) } if (noteBase == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt new file mode 100644 index 0000000000..c577c5d412 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -0,0 +1,133 @@ +/* + * 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.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +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.actions.ConcordActions +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.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The rich card form of a Concord invite link in note content — the analog of + * NIP-29's `RelayGroupCard`. Tapping the card opens the redeem/join flow + * ([Route.ConcordInvite], which keeps the full URL so the fragment token + * survives). It fetches + unlocks the kind-33301 bundle in the background (via + * [com.vitorpamplona.amethyst.model.Account.peekConcordInvite]) to fill in the + * community name; until then it shows a stable placeholder so layout never jumps. + * + * Degrades to [ClickableConcordInviteLink] (a plain link) if the URL doesn't parse. + */ +@Composable +fun ConcordInviteCard( + linkText: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) } + if (parsed == null) { + ClickableConcordInviteLink(linkText, nav) + return + } + + // Peek the bundle once per link to reveal the community name (null until it resolves). + val invite by produceState(initialValue = null, linkText) { + value = accountViewModel.account.peekConcordInvite(linkText) + } + + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + // Robohash seed: the community id once known (stable), else the link signer. + val robotSeed = invite?.communityId ?: parsed.linkSignerPubKey + val title = invite?.name?.takeIf { it.isNotBlank() } ?: stringRes(R.string.concord_home_title) + + ElevatedCard( + onClick = { nav.nav(Route.ConcordInvite(linkText)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = robotSeed, + model = null, + contentDescription = title, + modifier = + Modifier + .size(52.dp) + .clip(CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.concord_invite_card_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + SymbolIcon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = stringRes(R.string.concord_invite_card_join), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 08c1566830..5a45896216 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -575,6 +575,7 @@ private fun RenderWordWithPreview( is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav) + is ConcordInviteLinkSegment -> ConcordInviteCard(word.segmentText, accountViewModel, nav) is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index a3a3ec542a..2ee381764c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -190,6 +190,15 @@ class AccountFeedContentStates( } } + // Same for the Concord view mode (inline channels vs one row per community). + scope.launch(Dispatchers.IO) { + account.settings.concordViewMode + .drop(1) + .collect { + dmKnown.invalidateData() + } + } + // Pinning/unpinning a room only changes sort order, not membership, so no // chat event flows through LocalCache. Force a rebuild to re-sort. This // also fires when pins arrive via the synced AppSpecificData event. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 693accc760..1efa084f19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier @@ -119,6 +120,7 @@ fun ChatroomHeaderCompose( // would blank the row. val rendersWithoutEvent = baseNote is RelayGroupServerRoomNote || + baseNote is ConcordServerRoomNote || ( baseNote.event == null && baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true @@ -165,6 +167,11 @@ private fun ChatroomEntry( return } + if (lastMessage is ConcordServerRoomNote) { + ConcordServerRoomCompose(lastMessage, accountViewModel, nav) + return + } + val marmotGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } if (marmotGroup != null) { MarmotGroupRoomCompose(lastMessage, marmotGroup, accountViewModel, nav) @@ -511,6 +518,52 @@ private fun RelayGroupServerRoomCompose( ) } +@Composable +private fun ConcordServerRoomCompose( + row: ConcordServerRoomNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Community name/icon from the folded Control Plane (bumped via the session revision). + val revision by accountViewModel.account.concordSessions.revision + .collectAsStateWithLifecycle() + val metadata = + remember(row.communityId, revision) { + accountViewModel.account.concordSessions + .sessionFor(row.communityId) + ?.state + ?.value + ?.metadata + } + val name = metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(R.string.concord_home_title) + + val author = row.newestMessage?.author + val noteEvent = row.newestMessage?.event + val lastContent = + if (author != null && noteEvent != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${noteEvent.content.take(200)}" + } else { + stringRes(R.string.relay_group_no_messages_yet) + } + + ChannelName( + channelIdHex = row.communityId, + channelPicture = metadata?.icon, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.concord_server_label, modifier) }, + channelLastTime = row.newestMessage?.createdAt(), + channelLastContent = lastContent, + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.ConcordServer(row.communityId)) }, + ) +} + /** A small tappable chip naming the relay a channel is hosted on. */ @Composable private fun RelayNameChip( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index cdad33a5d5..c6def6253c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.util.replace @@ -130,22 +131,33 @@ class ChatroomListKnownFeedFilter( } } - // Concord Channels the user joined (kind 13302 list → folded Control Plane). Each folded - // channel is its own Messages row: its newest decrypted message (a real Note in LocalCache, - // attached to the ConcordChannel), or a placeholder for a just-joined channel with no - // messages yet. The note carries its ConcordChannel as a gatherer so the header renders it - // and a tap opens the encrypted chat — same shape as the Marmot/relay-group paths above. + // Concord Channels the user joined (kind 13302 list → folded Control Plane). In INLINE view + // mode each channel is its own Messages row (newest decrypted message — a real Note in + // LocalCache attached to the ConcordChannel — or a placeholder for a just-joined empty + // channel). In GROUPED mode all of a community's channels collapse into one community row + // positioned by that community's newest message. Concord groups by community exactly as + // NIP-29 groups by host relay above; both interleave with the rest of Messages by recency. val concordChannels = - account.concordSessions.sessions().flatMap { session -> - val state = session.state.value ?: return@flatMap emptyList() - state.channels.keys.map { channelIdHex -> - val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) - channel.notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } - .sortedByDefaultFeedOrder() - .firstOrNull() - ?: channel.placeholderNote() - } + when (account.settings.concordViewMode.value) { + ConcordViewMode.INLINE -> + account.concordSessions.sessions().flatMap { session -> + val state = session.state.value ?: return@flatMap emptyList() + state.channels.keys.map { channelIdHex -> + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) + channel.newestConcordNote(account) ?: channel.placeholderNote() + } + } + + ConcordViewMode.GROUPED -> + // One row per joined community, carrying the newest message across ALL its channels. + account.concordSessions.sessions().mapNotNull { session -> + val state = session.state.value ?: return@mapNotNull null + val newest = + state.channels.keys + .mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) } + .maxByOrNull { it.createdAt() ?: 0L } + ConcordServerRoomNote(session.entry.id, newest) + } } return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups + concordChannels).toSet()) @@ -287,28 +299,48 @@ class ChatroomListKnownFeedFilter( } } - /** The row a Concord note belongs to: its ConcordChannel gatherer's stable key. */ - private fun Note.concordRowKey(): String? = inGatherers?.firstNotNullOfOrNull { (it as? ConcordChannel)?.channelId?.toKey() } + /** + * The row a Concord note belongs to, so [updateListWith] can find and replace it: a per-community + * [ConcordServerRoomNote] (GROUPED), else the note's ConcordChannel gatherer keyed by channel + * (INLINE) or by community (GROUPED), depending on the current view mode. + */ + private fun Note.concordRowKey(): String? = + when (this) { + is ConcordServerRoomNote -> communityId + else -> + inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { ch -> + when (account.settings.concordViewMode.value) { + ConcordViewMode.INLINE -> ch.channelId.toKey() + ConcordViewMode.GROUPED -> ch.channelId.communityId + } + } + } /** - * Latest Concord message per joined channel from the new items, keyed the same way as - * [concordRowKey] (one row per channel). A Concord message note carries its ConcordChannel - * as a gatherer (attached on decrypt), and only kind-9/1111 message-like rumors are attached - * as rows — reactions/deletes wire to their target note and never become a room's last message. + * Latest Concord rows from the new items, keyed the same way as [concordRowKey]: by channel in + * INLINE mode (one row per channel) and by community in GROUPED mode (one row per community, + * carried as a [ConcordServerRoomNote]). A Concord message note carries its ConcordChannel as a + * gatherer (attached on decrypt); only message-like rumors are attached as rows — reactions/ + * deletes wire to their target note and never become a room's last message. */ private fun filterRelevantConcordMessages( newItems: Set, account: Account, ): MutableMap { - val result = mutableMapOf() + // Newest new message per channel (INLINE) or per community (GROUPED). + val grouped = account.settings.concordViewMode.value == ConcordViewMode.GROUPED + val newestPerKey = mutableMapOf() newItems.forEach { newNote -> - val key = newNote.concordRowKey() ?: return@forEach + val channel = newNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return@forEach if (newNote.event == null || !account.isAcceptable(newNote)) return@forEach - val lastNote = result[key] - if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { - result[key] = newNote - } + val key = if (grouped) channel.channelId.communityId else channel.channelId.toKey() + val last = newestPerKey[key] + if (last == null || (newNote.createdAt() ?: 0L) > (last.createdAt() ?: 0L)) newestPerKey[key] = newNote } + if (!grouped) return newestPerKey + // Wrap each community's newest into its collapsed server row. + val result = mutableMapOf() + newestPerKey.forEach { (communityId, note) -> result[communityId] = ConcordServerRoomNote(communityId, note) } return result } @@ -368,6 +400,13 @@ class ChatroomListKnownFeedFilter( .sortedByDefaultFeedOrder() .firstOrNull() + /** The newest decrypted message loaded in this Concord channel, or null if none yet. */ + private fun ConcordChannel.newestConcordNote(account: Account): Note? = + notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() + /** * The row a relay-group note belongs to in the feed, so [updateListWith] can find and replace it: * a per-relay [RelayGroupServerRoomNote] (GROUPED), a joined group's chat note keyed by group id diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt new file mode 100644 index 0000000000..d57feda237 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt @@ -0,0 +1,48 @@ +/* + * 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.rooms.dal + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A synthetic Messages-list row that collapses ALL of a user's channels in one Concord + * [communityId] into a single entry — the "grouped by community" view mode + * ([com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode.GROUPED]). It is the + * Concord analog of [RelayGroupServerRoomNote] (NIP-29 groups by host relay; Concord groups + * by community). + * + * It is not a real event: [event] stays null and [createdAt] mirrors [newestMessage] (the + * newest decrypted message across that community's channels) so the row interleaves with DMs + * and other chats by recency. Tapping it opens the community's channel list. Exactly one + * instance exists per community — keyed by a stable [idHex] so feed diffing and the LazyColumn + * treat it as the same row across refreshes. + */ +class ConcordServerRoomNote( + val communityId: HexKey, + val newestMessage: Note?, +) : Note(idFor(communityId)) { + override fun createdAt(): Long? = newestMessage?.createdAt() + + companion object { + fun idFor(communityId: HexKey): HexKey = "concordserver-$communityId" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt index edf13e06fe..0f0a0a2d67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -70,6 +71,8 @@ fun MessagesSettingsScreen( ) { val mode by accountViewModel.account.settings.relayGroupViewMode .collectAsStateWithLifecycle() + val concordMode by accountViewModel.account.settings.concordViewMode + .collectAsStateWithLifecycle() Scaffold( topBar = { @@ -87,24 +90,43 @@ fun MessagesSettingsScreen( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), ) - RelayGroupViewModeOption( + ViewModeOption( title = stringRes(R.string.relay_group_view_inline), description = stringRes(R.string.relay_group_view_inline_desc), selected = mode == RelayGroupViewMode.INLINE, onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, ) - RelayGroupViewModeOption( + ViewModeOption( title = stringRes(R.string.relay_group_view_grouped), description = stringRes(R.string.relay_group_view_grouped_desc), selected = mode == RelayGroupViewMode.GROUPED, onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, ) + + Text( + text = stringRes(R.string.concord_view_mode_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + ) + + ViewModeOption( + title = stringRes(R.string.concord_view_inline), + description = stringRes(R.string.concord_view_inline_desc), + selected = concordMode == ConcordViewMode.INLINE, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.INLINE) }, + ) + ViewModeOption( + title = stringRes(R.string.concord_view_grouped), + description = stringRes(R.string.concord_view_grouped_desc), + selected = concordMode == ConcordViewMode.GROUPED, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.GROUPED) }, + ) } } } @Composable -private fun RelayGroupViewModeOption( +private fun ViewModeOption( title: String, description: String, selected: Boolean, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index edd323241d..833e8e1df7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -342,6 +342,13 @@ Banned Join community Concord community invite + Concord invite (open the full invite link to join) + Concord + Concord community display + Inline + By community + Show each channel as its own conversation, mixed in with your chats. + Collapse each community\'s channels into a single row, placed at its newest message. encrypted legacy Looking for the original message… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt index 2974e8361d..a243b828b7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt @@ -34,5 +34,7 @@ enum class ConcordViewMode { companion object { val DEFAULT = INLINE + + fun fromName(name: String?): ConcordViewMode = entries.firstOrNull { it.name == name } ?: DEFAULT } }