mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+9
@@ -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.
|
||||
|
||||
+53
@@ -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(
|
||||
|
||||
+66
-27
@@ -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
|
||||
|
||||
+48
@@ -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"
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
@@ -34,5 +34,7 @@ enum class ConcordViewMode {
|
||||
|
||||
companion object {
|
||||
val DEFAULT = INLINE
|
||||
|
||||
fun fromName(name: String?): ConcordViewMode = entries.firstOrNull { it.name == name } ?: DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user