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
}
}