feat(concord): invite card, deep links, and Messages group-by-community

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-11 02:03:10 +00:00
parent 8326f08f43
commit c4657d482e
15 changed files with 399 additions and 33 deletions
+10
View File
@@ -193,6 +193,16 @@
<data android:host="iris.to" />
</intent-filter>
<!-- Concord community invite links: https://amethyst.social/invite/<naddr>#<fragment> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="amethyst.social" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -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),
@@ -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<GiftWrapEvent>) {
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)
}
/**
@@ -310,6 +310,13 @@ class AccountSettings(
}
}
fun updateConcordViewMode(mode: ConcordViewMode) {
if (concordViewMode.value != mode) {
concordViewMode.tryEmit(mode)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -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/<naddr>#<fragment>`). 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
}
@@ -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) {
@@ -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<CommunityInvite?>(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,
)
}
}
}
@@ -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)
}
@@ -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.
@@ -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(
@@ -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<Note>()
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<Note>()
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<Note>,
account: Account,
): MutableMap<String, Note> {
val result = mutableMapOf<String, Note>()
// Newest new message per channel (INLINE) or per community (GROUPED).
val grouped = account.settings.concordViewMode.value == ConcordViewMode.GROUPED
val newestPerKey = mutableMapOf<String, Note>()
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<String, Note>()
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
@@ -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"
}
}
@@ -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,
+7
View File
@@ -342,6 +342,13 @@
<string name="concord_role_banned">Banned</string>
<string name="concord_invite_card_join">Join community</string>
<string name="concord_invite_card_subtitle">Concord community invite</string>
<string name="concord_invite_naddr_label">Concord invite (open the full invite link to join)</string>
<string name="concord_server_label">Concord</string>
<string name="concord_view_mode_title">Concord community display</string>
<string name="concord_view_inline">Inline</string>
<string name="concord_view_grouped">By community</string>
<string name="concord_view_inline_desc">Show each channel as its own conversation, mixed in with your chats.</string>
<string name="concord_view_grouped_desc">Collapse each community\'s channels into a single row, placed at its newest message.</string>
<string name="chats_history_proto_nip17">encrypted</string>
<string name="chats_history_proto_nip04">legacy</string>
<string name="chats_reply_searching_history">Looking for the original message…</string>
@@ -34,5 +34,7 @@ enum class ConcordViewMode {
companion object {
val DEFAULT = INLINE
fun fromName(name: String?): ConcordViewMode = entries.firstOrNull { it.name == name } ?: DEFAULT
}
}