Merge pull request #3566 from vitorpamplona/claude/concord-quartz-amethyst-plan-0oy779

Concord: end-to-end-encrypted communities (CORD-01…07) on Android + CLI
This commit is contained in:
Vitor Pamplona
2026-07-14 23:14:36 -04:00
committed by GitHub
165 changed files with 17786 additions and 54 deletions
@@ -0,0 +1,208 @@
# Concord — Mobile Integration Plan (mirroring NIP-29 Relay Groups)
## Context
The Concord protocol engine is complete in `quartz/…/concord/` (CORD-01…07,
~65 tests) and driven end-to-end by the `amy concord` CLI over a commons
`ConcordActions` layer. This plan covers the **Android app integration**, and it
deliberately **mirrors the just-merged NIP-29 relay-groups feature** — that work
used Soapbox's Armada as a study base and established the exact Amethyst touch
points a group-chat protocol should plug into. Wherever possible we clone the
NIP-29 file structure with Concord equivalents rather than inventing parallels.
Naming: user-facing = **"Concord Channels"** (Amethyst reserves "community" for
NIP-72). Protocol-internal code keeps the spec term `community`.
## The one structural difference from NIP-29
NIP-29 group metadata (kind 39000) is **relay-signed and public**, so groups are
browsable. Concord communities are **end-to-end encrypted**: the only public
artifact is the addressable kind-33301 invite **bundle**, whose content is
token-gated. Consequences for the mirror:
- **Addressing** is by *derived stream pubkey* (`group_key.pk` per plane/epoch),
not `(hostRelay, groupId)`. A Concord channel lives at its plane address and
may be mirrored on several relays (the community's relay set), not pinned to
one host. So `ConcordChannel.relays()` = the community relay set.
- **Discovery** cannot preview E2EE content. The discovery feed surfaces **public
invite links** (kind-33301 bundles + links shared in notes), filtered by
author/hashtag — the entry action is *redeem a link*, not *browse contents*.
This is a genuinely thinner surface than NIP-29; documented, not a bug.
- **Membership = key possession**, verified locally from the folded Control Plane
+ banlist (already implemented), not from relay-signed 39001/39002.
## Per-account persistence & subscription model (Concord is between NIP-17 and NIP-28/29)
Separate **addressing** from **encryption/membership** and Concord's place is clear:
| Concern | NIP-28 | NIP-29 | NIP-17 | **Concord** |
|---|---|---|---|---|
| Find messages by | channel id | `(relay, h)` | `#p = me` | **`authors=[derived plane pk]`** |
| Content | public | public | E2EE to you | **E2EE to a shared key** |
| Decrypt with | — | — | your key | **per-channel derived conv key** |
| Membership | open | relay roster | key possession | **key possession** |
| "My rooms" home | follow list | kind-10009 | chatroom set | **kind-13302 (carries secrets)** |
The decisive point: a Concord wrap's `p` tag is **ephemeral**, so you can never
find messages with `#p = me` (the NIP-17 model). You subscribe **by author = the
derived plane pubkey** (NIP-28/29 addressing), a query only a secret-holder can
form, and decrypt with the shared plane key (NIP-17 E2EE).
**Home base = kind-13302 `ConcordCommunityList`** (built in quartz): NIP-44
self-encrypted, replaceable, relay-synced. Unlike NIP-17 (only secret is your
identity key) or NIP-29 (public group tags), **each entry carries the community
secrets** (`community_root`, salt, epoch, private-channel keys). Same trust model
as NIP-17's recoverable giftwrapped history: a leaked nsec exposes them, nothing
worse. `ConcordChannelListState` wraps 13302 exactly like `RelayGroupListState`
wraps 10009 / `EphemeralChatListState` wraps its list — **same wiring, entries
hold keys.**
**In-memory projection (LocalCache):** `ConcordChannel` keyed by
`(communityId, channelId)`, holding the folded Control-Plane state + decrypted
messages — recomputed from events, never persisted as identity (the NIP-28/29
half).
**Subscription = per-plane author REQ, fanned out from the joined list** — not a
single `#p=me` catch-all. `ConcordMyChannelsFilterAssembler` (mirrors NIP-29's
`RelayGroupMyJoinedGroupsFilterAssembler`) walks `account.concordChannelList`,
derives each community's control-plane + channel-plane addresses, and issues
`{kinds:[1059], authors:[planePk]}` per plane across the community's relays.
**Secrets at rest:** relay copy is self-NIP-44-encrypted (13302); the on-device
mirror can be wrapped with `commons/keystorage`.
## Layering (same as NIP-29)
- `quartz/…/concord/` — protocol (done)
- `commons/…/model/concord/``ConcordChannel`, `ConcordChannelListState`,
membership/view-mode enums, discovery constraint (platform-agnostic)
- `amethyst/…/chats/publicChannels/concord/` — screens, feed filters, datasource
subassemblers, navigation
- `commons/…/actions/ConcordActions.kt` — builders/filters/folding (done)
- `cli/…/commands/Concord*Commands.kt` — verbs (done; already matches the
`RelayGroupCommands` route+verb-map pattern)
## Mirror map (NIP-29 file → Concord equivalent)
### commons state
- `model/nip29RelayGroups/RelayGroupChannel.kt`**`model/concord/ConcordChannel.kt`**
— a `Channel` subclass keyed by a `ConcordChannelId(communityId, channelId)`,
holding the folded `ConcordCommunityState` + this channel's messages StateFlow,
`relays()` = community relay set, `membershipOf()` from the authority resolver,
`placeholderNote()`.
- `RelayGroupListState.kt`**`model/concord/ConcordChannelListState.kt`** —
backed by the **kind-13302** joined-communities list (already in quartz:
`ConcordCommunityList`). Exposes `liveCommunities: StateFlow<List<Entry>>` and
`liveServers: StateFlow<Set<communityId>>`. `join(community)`/`leave` do
read-modify-write of the 13302 event. Mirrors `EphemeralChatListState`.
- `RelayGroupMembership.kt`**`ConcordMembership.kt`** (OWNER/ADMIN/MEMBER/BANNED/
NONE) derived from `AuthorityResolver` (rank + banlist).
- `RelayGroupViewMode.kt`**`ConcordViewMode.kt`** (INLINE/GROUPED).
- `model/nip29RelayGroups/GroupDiscoveryConstraint.kt`**`ConcordDiscoveryConstraint.kt`**
(AllPublic / ByPeople / ByHashtags) matching against a public invite bundle.
### Account wiring (`amethyst/…/model/Account.kt`)
Add right after the `relayGroupList` lines (~382): a
`ConcordChannelListState(signer, cache, decryptionCache, scope, settings)` field
+ its decryption cache. Action methods next to `joinRelayGroup` (~1472):
`createConcordCommunity`, `joinConcordFromLink`, `postConcordMessage`,
`createConcordInvite`, `banConcordMember`, `follow/unfollow(ConcordChannel)`
delegate to `ConcordChannelListState`. Writes go through the community relay set.
Add `concordViewMode` to `AccountSettings.kt`.
### LocalCache (`amethyst/…/model/LocalCache.kt`)
Add a `LargeCache<ConcordChannelId, ConcordChannel>` index + `getOrCreateConcordChannel`,
and route inbound kind-1059 wraps on known plane addresses into the fold (decrypt
→ edition/message). Mirrors `getOrCreateRelayGroupChannel`.
### Messages inbox integration (THE key mirror)
- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` + `ChatroomListNewFeedFilter.kt`
— extend the 5-way `feed()` concatenation to **6-way**: add a `concordChannels`
block reading `account.concordChannelList.liveCommunities`, branching on
`concordViewMode` (INLINE = one row per channel via
`LocalCache.getOrCreateConcordChannel(...).newestChatNote() ?: placeholderNote()`;
GROUPED = one synthetic `ConcordServerRoomNote(communityId, newest)` per
community). Update `applyFilter`/`updateListWith` with a
`filterRelevantConcordMessages(...)` keyed by `concordRowKey()`.
- `chats/rooms/dal/RelayGroupServerRoomNote.kt`**`ConcordServerRoomNote.kt`** —
synthetic event-less Note collapsing a community's channels into one inbox row.
- `chats/rooms/ChatroomHeaderCompose.kt` — add `rendersWithoutEvent` branches for
`ConcordServerRoomNote` and channel placeholders; `ConcordServerRoomCompose`
`Route.ConcordServer(communityId)`; `ConcordRoomCompose` (chip = community name)
`routeFor(channel)`. **This is where the "chip opens the Concord Channel"
requirement lands.**
### Screens (`amethyst/…/chats/publicChannels/concord/`, mirror `relayGroup/`)
- `ConcordServerList.kt` (community rows) · `ConcordChannelListScreen.kt(communityId)`
(a community's channels, from the folded Control Plane) ·
`ConcordChatScreen.kt(communityId, channelId, …)` (top-level route target) ·
`ConcordChannelView.kt` (reuse the NIP-28 `ChannelFeedViewModel`/`ChannelView`
stack via the `ConcordChannel: Channel` subclass) · `ConcordMembersScreen.kt` ·
`ConcordMetadataScreen.kt`/`ViewModel.kt` (create/edit) · `ConcordTopBar.kt`
(name + role badge + Members/Edit/Invite/Ban/Leave menu) · `LoadConcordChannel.kt`.
- Compose composer gated on `membershipOf(me).isMember()`; else a "redeem an
invite to post" notice.
### Discovery feed (GitRepositories-style triad; thinner than NIP-29)
- `concord/dal/ConcordDiscoveryFeedFilter.kt` (`AdditiveFeedFilter<Note>` over
public kind-33301 bundles; "My Communities" branch = the 13302 list) +
`concord/dal/ConcordDiscoveryConstraint.kt` bridge +
`concord/datasource/subassemblies/FilterConcordBundlesBy{Authors,Follows,Hashtag}.kt`.
`ConcordDiscoveryScreen.kt` = `DisappearingScaffold` + `FeedFilterSpinner` +
`RenderFeedContentState` with `ConcordDiscoveryCard` (name + Join button). FAB →
`ConcordBrowse`/redeem-link.
### Navigation (`ui/navigation/routes/Routes.kt` + `AppNavigation.kt`)
`@Serializable` routes: `Concord`(communityId, channelId, +draftId?/inviteToken?),
`ConcordServer`(communityId), `ConcordMembers`, `ConcordCreate`, `ConcordEdit`,
`Concords`(object, bottom-nav → discovery), `ConcordBrowse`. `RouteMaker.routeFor(ConcordChannel)`
+ deep-link: an invite URL/`nostr:`-embedded link → `Route.Concord(..., inviteToken=…)`,
auto-redeeming on open (mirror NIP-29's inviteCode auto-join). Wire through
`BouncingIntentNav.kt`.
### Invite/redeem UI + linkification
- `InviteConcordDialog.kt` (moderator: mint + share link via `ConcordActions.mintInviteLink`)
· `JoinConcordDialog.kt` (paste a link → redeem) · `ui/components/ConcordInviteCard.kt`
(render a link as a preview card; tap → `Route.Concord(inviteToken)`) ·
`ui/components/ClickableConcordInviteLink.kt` (inline linkify shared invite URLs).
### Notifications (your explicit ask)
Route a Concord message notification click to the **channel chat**, not the feed:
in the notification builder + `BouncingIntentNav`, map a Concord message
notification to `Route.Concord(communityId, channelId)`. Mirror how NIP-29
group notifications resolve via `routeFor`.
### Zaps & likes
Because `ConcordChannel` extends `Channel` and messages render through the shared
`ChannelView`, reactions (kind 7) and zaps attach through the existing chat
reaction/zap path — but they must be **wrapped on the channel plane** (kind-7/9735
rumors sealed like messages, bound to channel+epoch), not published in the clear.
Add `ConcordActions.buildReaction`/`buildZapRequest` that wrap on the plane, and
point the shared reaction/zap affordances at them for Concord notes.
## Build order (each a tested, shippable slice)
1. **commons foundation**`ConcordChannel`, `ConcordChannelListState` (13302),
membership/view-mode enums; unit tests. Wire into `Account.kt` + `AccountSettings`.
2. **LocalCache index** + inbound wrap folding.
3. **Messages inbox** 6-way concat + `ConcordServerRoomNote` + header render/nav
(delivers the chip-opens-channel behavior).
4. **Chat screens** (reuse NIP-28 `ChannelView`) + nav routes + create/invite/join.
5. **Discovery feed** triad (public invite bundles).
6. **Notifications routing + zaps/likes on-plane.**
## Verification
- commons: `:commons:jvmTest` unit tests for `ConcordChannelListState` (13302
round-trip/merge) and `ConcordChannel` folding, mirroring
`RelayGroupListDecryptionTest`/`RelayGroupChannelTest`.
- Android: `:amethyst:installDebug`; create a community, see it in Messages with a
chip, tap → channel opens, send/receive between two emulators, redeem an invite
link deep-link, verify a notification click opens the chat. Cross-check against
`amy concord` (same relay) for wire interop, and against Armada for protocol
interop (`Nip29ArmadaInteropTest` is the precedent).
## Gotchas carried from the NIP-29 study
- Membership has two independent layers (Concord authority vs NIP-43 relay
membership); we only implement Concord authority.
- Cache-as-floor + optimistic local signing for snappy UX.
- E2EE means no server-side moderation and no metadata preview — surface state
from the local fold only.
@@ -0,0 +1,113 @@
# Dual-mode replies: inline + "minichat" threads across all chats
## Goal
Give every Amethyst chat two ways to reply, chosen at send time:
- **Inline reply** — a normal chat message that references its parent and stays in
the main timeline (today's behavior). On the wire this is the chat protocol's
native reply: NIP-C7 kind-9 with a `q` quote (Concord), kind-42 reply (NIP-28),
kind-9 `+h` reply (NIP-29), kind-14 reply (NIP-17 DM).
- **Minichat reply** — a **kind-1111 NIP-22 `CommentEvent`** rooted at the parent
message. It is pulled *out* of the main timeline and shown in a separate
**minichat** ("chat within a chat") opened from the parent. This matches Soapbox
Armada exactly (kind-9 `q` = inline quote, kind-1111 = thread).
The rule is uniform and protocol-agnostic: **any kind-1111 whose root is a chat
message opens as that message's minichat.** So the same treatment automatically
covers Concord kind-9, NIP-28 kind-42, NIP-29 kind-9, and (later) NIP-17 kind-14 —
wherever a 1111 lands on a chat message.
## Reuse survey (what already exists — do NOT rebuild)
| Need | Reuse |
|---|---|
| kind-1111 reply builder (NIP-22 `K/E/P`+`k/e/p`) | `quartz/.../nip22Comments/CommentEvent.replyBuilder`; Concord's `ChannelChat.reply` already uses it |
| 1111 → parent wiring | `LocalCache.computeReplyTo` (CommentEvent branch) → `parentNote.replies`; minichat content = `note.replies.filter { it.event is CommentEvent }` |
| "N replies" chip | `observeNoteReplyCount(note, avm)` (EventObservers.kt) — already used by `RelayGroupThreadsScreen` |
| Shared per-row action strip | `ChatMessageCompose.NormalChatNote` `detailRow` `Row` — one place, every chat type |
| Thread rendering | `threadview/ThreadFeedView` + `ThreadAssembler.findThreadFor`; NIP-29 `RelayGroupThreadsScreen` as the chat-adjacent precedent |
| Per-message 1111 REQ (public chats) | `FilterRepliesAndReactionsToNotes` (kinds incl 1111, `#e`) via `EventFinder`; `RelayGroupThreadFeedFilterAssembler` (compose-scoped `#h`+1111 sub) |
| Composer reply state + "replying-to" preview | `*NewMessageViewModel.replyTo` + `chats/utils/DisplayReplyingToNote` |
| NIP-22 comment composer | `note/nip22Comments/CommentPostViewModel` (full-featured) |
Concord already delivers kind-1111 replies through the existing channel-plane
subscription (they're wrapped like every other rumor), so **no new subscription is
needed for Concord** — only the timeline split, the chip, the minichat screen, and
the composer picker.
## Design
### 1. Wire model (settled — matches Armada)
- Inline reply → native chat reply event, native reply tags, stays in timeline.
- Minichat reply → kind-1111 `CommentEvent`: uppercase `K/E/P` at the immutable
thread root (the chat message), lowercase `k/e/p` at the immediate parent, plus
whatever binding the plane requires (Concord: `channel`/`epoch`). One level:
replying inside a minichat roots the new 1111 at the **same** root message
(parent = the message being answered, root = the minichat root), rendered flat —
so minichat messages don't spawn sub-threads. (The wire still permits nesting;
we render flat.)
### 2. Timeline vs minichat split (rendering)
- **Main feed** excludes kind-1111 comments whose root is a chat message — they
live in the minichat, not as flat siblings. Implemented in the shared
`ChannelFeedFilter` / `ChatroomFeedFilter` by dropping `CommentEvent`s that root
onto a message already in the feed (keep everything else).
- Each root message row shows an **"N replies" chip** (from `observeNoteReplyCount`
restricted to CommentEvent replies) in the `detailRow` strip; tap → minichat route.
### 3. Minichat screen
- A thread screen keyed by the **root message id** (+ the channel/room key needed to
re-derive the plane / re-subscribe). Renders the root message pinned at top, then
its kind-1111 replies as a flat mini-timeline (reuse `ChatroomMessageCompose`), with
its own composer that always sends kind-1111 rooted at this message.
- Back it with `ThreadFeedView`/`ThreadAssembler` where possible; for Concord, feed
it from `rootNote.replies` (already populated) + a lifecycle sub that keeps the
plane live.
### 4. Composer mode picker
- Add `replyMode: ReplyMode {INLINE, MINICHAT}` next to `replyTo` in each
`*NewMessageViewModel` (Concord `ConcordNewMessageViewModel`, DM
`ChatNewMessageViewModel`, channels `ChannelNewMessageViewModel`).
- Render a small toggle beside `DisplayReplyingToNote` ("Reply in chat" ⇄ "Reply in
thread"). Default = INLINE (least surprise; user opts into pulling it aside).
- Send branch: `MINICHAT` routes to the kind-1111 builder
(`CommentEvent.replyBuilder` / Concord `buildChannelReply`), `INLINE` keeps the
native reply builder.
### 5. Subscriptions
- **Concord**: none new (1111 arrives via the channel plane). Just ensure the
timeline filter and minichat read `rootNote.replies`.
- **NIP-28 / NIP-29 (phase 2)**: add a compose-scoped assembler (clone
`RelayGroupThreadFeedFilterAssembler`) that REQs `{kinds:[1111], "#e":[<visible
message ids>]}` (and `#E`) off the feed's current message-id set (from
`FeedContentState`). Reuse the same minichat screen/row.
- **NIP-17 DM (phase 3, later)**: kind-1111 replies must be gift-wrapped like the
kind-14s; deferred — needs an encrypted-comment path, more design.
## Phasing
1. **Phase 1 — Concord, full UX + all shared pieces.** ReplyMode enum + composer
toggle; timeline split (drop chat-rooted 1111s); "N replies" chip in the shared
`detailRow`; minichat route + screen; Concord send branch. Delivers the complete
dual-mode experience for Concord and builds every shared component.
2. **Phase 2 — public chats.** Per-message 1111 subscription for NIP-28 + NIP-29;
reuse the Phase-1 chip/screen/composer. NIP-29 already has a thread screen to
reconcile with.
3. **Phase 3 — DMs.** Gift-wrapped kind-1111 minichat for NIP-17. Deferred.
## Decisions (settled)
- **Default mode** when tapping reply: **INLINE**. User opts into MINICHAT via the toggle.
- **Minichat depth**: **flat, one level**. Replying inside a minichat roots at the
same message; no sub-threads.
- **Scope now**: **Phase 1 + 2 together** — Concord AND public chats (NIP-28/NIP-29).
DMs (phase 3) still deferred.
- **Screen styling**: **chat-styled bubbles** (reuse `ChatroomMessageCompose`) so the
minichat reads as "a chat within a chat".
## Verification
- quartz/commons unit tests for the reply-mode builders + the timeline-filter split
(a chat-rooted 1111 is excluded from the feed but present in `rootNote.replies`).
- On-device: in Concord, reply inline (stays in timeline) and reply-in-thread (opens
minichat); confirm Armada shows our minichat replies as a thread and its threads
open as our minichat; confirm the "N replies" chip count.
@@ -0,0 +1,83 @@
# CORD-06 Refounding — real member removal for Concord
## Problem
Concord membership is key possession: a banned member (CORD-04 banlist) still
holds the community's `community_root`, so every client just *declines to show*
their posts — they can still decrypt everything. That is a soft removal. CORD-06
adds the hard removal: rotate the key so a removed member's key stops working for
anything sent afterwards.
The quartz crypto for the kind-3303 rekey blob (`ConcordRekey`, `RekeyBlob`)
already existed and was tested, but nothing in the app called it. This wires the
whole path — build, publish, receive, persist, UI — around a **Refounding**
(whole-community rotation), the removal that matters while Amethyst supports only
public channels (a per-channel rekey needs private channels, not built yet).
## What a Refounding does (CORD-06 §3)
1. Ban the removed members on the current Control Plane (so the compacted snapshot
carries the ban).
2. Roll `community_root` to a fresh random 32 bytes at `rootEpoch + 1`. Public
channels + the Control/Guestbook planes all derive from the root, so rolling it
rotates every plane at once.
3. Republish the **compacted** Control Plane under the new root — keep only each
entity's head edition and re-wrap its *original plaintext seal*, so the original
authors' signatures survive re-encryption (a fresh joiner verifies the slim
state exactly as it verified the full chain).
4. Mint per-recipient kind-3303 rekey blobs delivering the new root to every
retained member, sealed + addressed under the **prior** root on the
`base-rekey-pseudonym(prior_root, community_id, new_epoch)` address — which every
current member precomputes, so they receive it live. A removed member gets no
blob and can never derive the new root.
## Layers
- **quartz** `concord/cord06Rekey/`
- `ConcordKeyDerivation`: `baseRekeyAddress` / `channelRekeyAddress` (the rekey
stream addresses), `epochKeyCommitment` (`prevcommit`, CORD-02 §A.5).
- `ConcordRekey`: signer-based `blobForSigner` / `findNewKeyWithSigner` (bunker
accounts open a blob with one `nip44Decrypt`, no raw key).
- `ConcordRefounding`: `compactControlPlane`, `buildBaseRekeyWraps`, `build`
(whole refounding), `findNewRoot` (receive: verify scope/epoch/continuity, find
my blob). `OpenedStreamEvent` now also carries the inner `seal` so compaction
can re-wrap it. Tests in `ConcordRefoundingTest`.
- **commons**
- `ConcordActions`: `guestbookPlane` / `nextBaseRekeyPlane`, `buildGuestbookJoin`
/ `guestbookMembers`, `buildRefounding`, `openBaseRekey`.
- `ConcordCommunitySession`: folds the Guestbook plane into `members`
(the recipient set), buffers inbound base-rekey wraps (`pendingBaseRekeyWraps`),
exposes `controlPlaneWraps` for compaction, and AUTHs to + subscribes the
Guestbook and next-epoch base-rekey planes (`streamKeys`, `subscribeAddresses`).
- `ConcordSessionRegistry.sync`: rebuilds a session when its entry's root/epoch
changed — the session is a pure function of its entry, so adopting a new root is
just a persisted entry swap.
- `ConcordSubscriptionPlanner.auxiliaryPlaneSubs`: REQs the Guestbook + next
base-rekey planes for every joined community.
- **amethyst**
- `Account`: announces a Guestbook JOIN on create/join (`announceConcordGuestbookJoin`)
so members are visible to a future rotator; `refoundConcordCommunity` (owner /
BAN-holder) bans + rolls + publishes + persists; `drainConcordRekeys` (revision
tick) adopts an inbound rotation from an authorized rotator; `adoptConcordRoot`
persists the new root (prior root kept as a `HeldRoot`) and re-seeds the new
epoch's Guestbook, guarded against double-adopt.
- `AccountViewModel.removeConcordMember`; `ConcordMembersScreen` "Remove from
community" action + confirm dialog, gated exactly like Ban.
## Recipient set
The rotator re-keys **Guestbook membership the privileged roster self**, minus
the removed and the already-banned. The Guestbook is best-effort/off-consensus, so
a member who joined but whose Guestbook JOIN hasn't propagated to the rotator would
be missed and locked out — the accepted trade for a serverless, key-possession
membership model. Adopting a new root re-announces the Guestbook JOIN at the new
epoch so cascading removals keep a live membership.
## Known limitations / follow-ups
- No explicit "you were removed" detection: a removed member simply stops receiving
new content (their old-epoch keys still read history). CORD-06's "held all n
chunks, none is mine ⇒ removed" self-eviction is not implemented.
- Per-channel rekey (single private channel) is not wired — needs private channels.
- Race convergence (two rotators, same epoch, lexicographically-lowest-key wins) is
not implemented; single-rotator (owner/admin) refounding is the supported path.
@@ -97,7 +97,7 @@ class EventSyncTest {
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { authTemplate ->
signWithAllLoggedInUsers = { _, authTemplate, _ ->
listOf(signer.sign(authTemplate))
},
)
+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),
@@ -21,11 +21,17 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState
@@ -61,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
@@ -138,6 +145,18 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -168,6 +187,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
@@ -175,9 +196,11 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -219,6 +242,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
@@ -309,6 +333,7 @@ import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -319,6 +344,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -330,6 +356,9 @@ import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as gallery
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
/** Name of the default Concord community Admin role minted by "Make admin". */
private const val CONCORD_ADMIN_ROLE = "Admin"
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
@@ -441,6 +470,89 @@ class Account(
val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer)
val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings)
val concordChannelList = ConcordChannelListState(signer, cache, scope, settings)
/**
* The live read-path for joined Concord Channels: one folding session per
* community, fed by inbound kind-1059 plane wraps. Kept in step with
* [concordChannelList] and consulted by the giftwrap decrypt path so a Concord
* plane wrap routes here instead of being dropped as an undecryptable DM.
*/
val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope, ::consumeConcordRumorGated)
/**
* Sink for decrypted Concord rumors: drops a message whose author is banned in
* the community's current fold before it ever becomes a Note, then delegates to
* the cache. Bans that arrive *after* a message are handled by removing the
* author's existing notes on re-fold (see `refreshConcordChannelIndex`); this
* gate stops *new* posts from a banned author from appearing at all.
*/
private fun consumeConcordRumorGated(
communityId: String,
channelIdHex: String,
rumor: Event,
) {
val authority =
concordSessions
.sessionFor(communityId)
?.state
?.value
?.authority
if (authority?.isBanned(rumor.pubKey) == true) return
registerConcordEncryptedImages(rumor)
cache.consumeConcordRumor(communityId, channelIdHex, rumor)
}
/**
* Register any encrypted image attachments on a Concord message ([ChannelChat.encryptedImagesOf])
* so the shared media pipeline can display them: the ciphertext blob's AES-256-GCM key/nonce go
* into [com.vitorpamplona.amethyst.AppModules.keyCache], and the OkHttp EncryptedBlobInterceptor
* decrypts the blob transparently on fetch (keyed by URL) — the same path NIP-17 encrypted media
* uses. Runs for both inbound wraps and our own local echo, so a sent image renders immediately.
*/
private fun registerConcordEncryptedImages(rumor: Event) {
val images = ChannelChat.encryptedImagesOf(rumor)
if (images.isEmpty()) return
val keyCache = Amethyst.instance.keyCache
images.forEach { img ->
if (img.algo == AESGCM.NAME) {
keyCache.add(img.url, AESGCM(img.key, img.nonce), img.mimeType)
}
}
}
/**
* Copies each folded community's metadata (name/icon, channel flags, this account's
* membership) onto its [ConcordChannel] objects in the cache, and drops messages from
* authors banned since they loaded. Runs account-wide on every
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager] revision —
* NOT gated behind the Concord hub screen — so every surface (the Messages-tab
* community chip, the chat screen title) reflects the current fold, and bans apply,
* even when the hub was never opened.
*/
fun refreshConcordChannelIndex() {
val myPubKey = signer.pubKey
val relaysByCommunity =
concordChannelList.liveCommunities.value.associate { entry ->
entry.id to entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
}
for (session in concordSessions.sessions()) {
val state = session.state.value ?: continue
val communityId = session.entry.id
val relays = relaysByCommunity[communityId] ?: emptySet()
for (channelIdHex in state.channels.keys) {
val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
// Invalidate the channel's metadata flow only on a real change so the Messages-row
// name + community chip recompose when the fold first resolves them (they observe
// metadata.stateFlow via observeChannel), without churning every row every tick.
if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo()
channel.notes
.filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true }
.forEach { channel.removeNote(it) }
}
}
}
val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer)
val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings)
@@ -1815,6 +1927,709 @@ class Account(
suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel))
/**
* Add a joined Concord community (secret-bearing entry) to the private kind-13302
* list, and announce a self-signed Guestbook JOIN so this member is visible to
* whoever later refounds the community (CORD-06 re-keys the Guestbook membership).
*/
suspend fun joinConcordCommunity(
entry: ConcordCommunityListEntry,
inviteCreator: HexKey? = null,
inviteLabel: String? = null,
) {
sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry))
announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel)
}
/** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */
private suspend fun announceConcordGuestbookJoin(
entry: ConcordCommunityListEntry,
inviteCreator: HexKey?,
inviteLabel: String?,
) {
if (!isWriteable()) return
val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildGuestbookJoin(signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel)
concordSessions.ingest(wrap)
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isNotEmpty()) client.publish(wrap, relays)
}
/**
* Create a new Concord community: mint its genesis (metadata + #general),
* publish the owner-signed genesis wraps to [relays] (or our outbox), and add
* the secret-bearing entry to the kind-13302 joined list. Returns the new
* community id, or null if not writeable.
*/
suspend fun createConcordCommunity(
name: String,
description: String? = null,
relays: List<String> = emptyList(),
icon: ImagePointer? = null,
): String? {
if (!isWriteable()) return null
val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } }
val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls, icon)
val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value }
community.genesisWraps.forEach { client.publish(it, publishTo) }
joinConcordCommunity(
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = relayUrls,
name = name,
addedAt = TimeUtils.now() * 1000,
),
)
return community.communityIdHex
}
/**
* Mint a shareable invite link for a joined community and publish its
* kind-33301 public bundle to the community relays. Returns the `…/invite/…`
* URL, or null if the community isn't joined or isn't writeable.
*/
suspend fun mintConcordInvite(
communityId: String,
base: String = "https://amethyst.social",
): String? {
if (!isWriteable()) return null
val entry = concordChannelList.liveCommunities.value.firstOrNull { it.id == communityId } ?: return null
val invite =
ConcordActions.inviteFor(
communityIdHex = entry.id,
ownerPubKey = entry.owner,
ownerSaltHex = entry.ownerSalt,
communityRootHex = entry.root,
rootEpoch = entry.rootEpoch,
name = entry.name,
relays = entry.relays,
)
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value }
if (publishTo.isNotEmpty()) client.publish(minted.bundleEvent, publishTo)
return minted.url
}
/** Drop a joined Concord community from the private kind-13302 list by its id. */
suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId))
/**
* Redeem a Concord invite link (`…/invite/<naddr>#<fragment>`): parse it, fetch
* the kind-33301 public bundle from the link's relays (+ our outbox), unlock it
* with the fragment token, and add the resulting secret-bearing entry to the
* kind-13302 joined list. Returns the joined community id, or null if the link
* is invalid, unreadable, or no valid bundle is found.
*/
suspend fun joinConcordViaInvite(url: String): String? {
if (!isWriteable()) return null
val parsed = ConcordActions.parseInviteLink(url) ?: return null
val relays =
(parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet()
if (relays.isEmpty()) return null
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } ?: return null
val entry =
ConcordCommunityListEntry(
id = bundle.communityId,
owner = bundle.owner,
ownerSalt = bundle.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
)
joinConcordCommunity(entry)
return bundle.communityId
}
/**
* Post [text] to a Concord channel: derive the channel plane key, build an
* encrypted-seal kind-1059 wrap authored by that plane key (not our identity),
* fold it locally for an instant echo, and publish it to the community's relays.
* The `p` tag is ephemeral, so this never routes through the DM outbox — it goes
* straight to the community relay set. Returns false if not writeable or the
* community isn't currently joined/folded.
*/
suspend fun sendConcordChannelMessage(
communityId: String,
channelIdHex: String,
text: String,
replyTo: Note? = null,
replyMode: ReplyMode = ReplyMode.INLINE,
): Boolean {
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
// NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the
// custom image everywhere (the kind-9 rumor carries them; recipients render via the tags).
val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray()
val parent = replyTo?.event
val wrap =
when {
// A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9
// message quoting the parent; a fresh post is a plain kind-9 message.
parent != null && replyMode == ReplyMode.MINICHAT ->
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
parent != null ->
ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
else ->
ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags)
}
publishConcordWrap(entry, wrap)
return true
}
/**
* Send a channel message carrying encrypted image attachments ([imetas], built by the composer
* from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are
* appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no
* attachments this is just a plain [sendConcordChannelMessage].
*/
suspend fun sendConcordChannelImageMessage(
communityId: String,
channelIdHex: String,
text: String,
imetas: List<IMetaTag>,
): Boolean {
if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text)
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message.
val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
/**
* Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that
* message. Resolves the chat context from the note's gatherer; today it drives the
* Concord channel path (NIP-28/NIP-29 public-chat minichats are a follow-up). Returns
* false if the message isn't in a chat we can post a thread reply to.
*/
suspend fun sendMinichatReply(
rootNote: Note,
text: String,
): Boolean {
if (!isWriteable()) return false
val gatherers = rootNote.inGatherers
gatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { concord ->
return sendConcordChannelMessage(
concord.channelId.communityId,
concord.channelId.channelId,
text,
rootNote,
ReplyMode.MINICHAT,
)
}
// Public chats: a plain public kind-1111 comment rooted at the message. NIP-29 groups
// additionally carry the `h` tag and go only to the host relay.
val rootEvent = rootNote.event ?: return false
gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat ->
val relays = chat.relays()
val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull())))
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, relays.ifEmpty { outboxRelays.flow.value })
return true
}
gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.let { group ->
val hostRelay = group.groupId.relayUrl
val signed =
signer.sign(
CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) {
hTag(group.groupId.id)
},
)
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, setOf(hostRelay))
return true
}
return false
}
/**
* React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors
* [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's
* channel/epoch, wraps it on the plane, and publishes it — so the reaction stays
* inside the encrypted channel (never a plaintext public kind-7 that would leak
* the message id). [note] must be a Concord channel message (carries a
* [ConcordChannel] gatherer).
*/
suspend fun reactToConcordMessage(
note: Note,
reaction: String,
): Boolean {
if (!isWriteable()) return false
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false
val target = note.event ?: return false
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
// A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to
// resolve to an image on the other side; a plain unicode/`+` reaction yields no tags.
val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags)
publishConcordWrap(entry, wrap)
return true
}
/**
* Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at
* most every few seconds while composing. Not folded locally (we never show our own typing);
* ephemeral, so relays broadcast but never store it.
*/
suspend fun sendConcordTyping(
communityId: String,
channelIdHex: String,
) {
if (!isWriteable()) return
val entry = concordSessions.sessionFor(communityId)?.entry ?: return
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isNotEmpty()) client.publish(wrap, relays)
}
/** Instant local echo (the session folds it back as a Note) + publish to the community relays. */
private fun publishConcordWrap(
entry: ConcordCommunityListEntry,
wrap: Event,
) {
concordSessions.ingest(wrap)
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isNotEmpty()) client.publish(wrap, relays)
}
// ── Concord roles & moderation (CORD-04) ─────────────────────────────────
// Each publishes a Control Plane edition; authority is enforced at fold time by
// every client's AuthorityResolver, so a call by someone who doesn't outrank the
// target is simply dropped on fold. Owner-authored calls always take effect.
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
suspend fun grantConcordRole(
communityId: String,
member: HexKey,
roleIds: List<String>,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/** The default community Admin role: position 1, holding every management + moderation permission. */
private fun concordAdminRole() =
RoleEntity(
name = CONCORD_ADMIN_ROLE,
position = 1,
permissions =
ConcordPermissions
.of(
ConcordPermissions.MANAGE_ROLES,
ConcordPermissions.MANAGE_CHANNELS,
ConcordPermissions.MANAGE_METADATA,
ConcordPermissions.KICK,
ConcordPermissions.BAN,
ConcordPermissions.MANAGE_MESSAGES,
ConcordPermissions.CREATE_INVITE,
).toWire(),
)
/**
* If [note] is a Concord channel message whose author the OWNER may toggle
* "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner
* qualifies — the Admin role sits at position 1 and the resolver requires the
* granter to *strictly* outrank it, which only the owner (rank 0) does. Null for
* the owner's own note, the owner as target, or a non-owner actor.
*/
fun concordAdminTarget(note: Note): Triple<String, HexKey, Boolean>? {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
if (author == signer.pubKey) return null
val communityId = channel.channelId.communityId
val state = concordSessions.sessionFor(communityId)?.state?.value ?: return null
if (state.authority.isOwner(author) || !state.authority.isOwner(signer.pubKey)) return null
val adminRoleId =
state.roles.entries
.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L }
?.key
val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author)
return Triple(communityId, author, isAdmin)
}
/** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */
suspend fun makeConcordAdmin(
communityId: String,
member: HexKey,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val cp = session.controlPlaneKey()
val existing =
session.state.value
?.roles
?.entries
?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L }
val roleIdHex =
existing?.key ?: run {
val roleId = RandomInstance.bytes(32)
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, roleWrap)
roleId.toHexKey()
}
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, grantWrap)
return true
}
/** Revoke all roles from [member] (demote an admin back to a plain member). */
suspend fun removeConcordAdmin(
communityId: String,
member: HexKey,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, grantWrap)
return true
}
/**
* If [note] is a Concord channel message whose author this account is allowed to
* ban — the actor is the owner or holds the BAN permission, and the target is
* neither the owner nor the actor — returns `(communityId, memberHex)`. Null
* otherwise, so the UI shows the Ban action only when it would actually take
* effect on fold.
*/
fun concordBanTarget(note: Note): Pair<String, HexKey>? {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
if (author == signer.pubKey) return null
val communityId = channel.channelId.communityId
val authority =
concordSessions
.sessionFor(communityId)
?.state
?.value
?.authority ?: return null
if (authority.isOwner(author)) return null
val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
return if (canBan) communityId to author else null
}
/** Add [member] to the community banlist. */
suspend fun banConcordMember(
communityId: String,
member: HexKey,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/** Remove [member] from the community banlist. */
suspend fun unbanConcordMember(
communityId: String,
member: HexKey,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
// ── Concord refounding / rekey (CORD-06) ──────────────────────────────────
// A ban is a soft removal — the banned member still holds the room key and can
// still decrypt traffic; every client just declines to *show* their posts. A
// Refounding is the hard removal: it rotates the community_root, so a removed
// member's key stops working for anything published afterwards.
/**
* Remove [removed] from the community absolutely (CORD-06 Refounding): ban them,
* roll the `community_root`, re-key every retained member (Guestbook membership
* the privileged roster self) via kind-3303 blobs, and republish the compacted
* Control Plane under the new root. A removed member keeps the prior root (so
* their history stays readable) but receives no blob, so they can never decrypt
* anything published after the rotation.
*
* Requires ownership or the BAN permission; returns false otherwise (or if the
* community isn't joined/writeable, or a target is the owner).
*/
suspend fun refoundConcordCommunity(
communityId: String,
removed: Set<HexKey>,
): Boolean {
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
val state = session.state.value ?: return false
val authority = state.authority
val iCanBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
if (!iCanBan) return false
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
// 1. Ban the removed members on the current Control Plane so the compacted snapshot —
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
// first, so each subsequent edition chains onto the updated banlist head.
for (target in removedLower) {
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, banWrap)
}
// 2. Recipient set: everyone we're keeping — Guestbook joins roster self, minus the
// removed and the already-banned.
val recipients =
(session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey)
.mapTo(HashSet()) { it.lowercase() }
.apply {
removeAll(removedLower)
removeAll(authority.bannedMembers())
}.toList()
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
val entry = session.entry
val newRoot = RandomInstance.bytes(32)
val build =
ConcordActions.buildRefounding(
rotatorSigner = signer,
communityId = communityId,
priorRoot = entry.root.hexToByteArray(),
newRoot = newRoot,
rootEpoch = entry.rootEpoch,
priorControlWraps = session.controlPlaneWraps(),
priorControlKey = session.controlPlaneKey(),
recipientsXOnly = recipients,
createdAt = TimeUtils.now(),
)
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
// (the key that unlocks it) to the community relays.
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (publishTo.isNotEmpty()) {
build.controlWraps.forEach { client.publish(it, publishTo) }
build.rekeyWraps.forEach { client.publish(it, publishTo) }
}
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
adoptConcordRoot(entry, newRoot, build.newEpoch)
return true
}
// Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered
// in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not
// adopted — and re-published — twice on successive revision ticks.
private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet<String>())
/**
* Persist a rotated access root/epoch for [entry], keeping the prior root as a
* [HeldRoot], and re-announce our Guestbook membership at the new epoch so the
* fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership —
* without this, cascading removals would lose everyone but the roster). No-op if
* this exact rotation was already adopted.
*/
private suspend fun adoptConcordRoot(
entry: ConcordCommunityListEntry,
newRoot: ByteArray,
newEpoch: Long,
) {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch }
val next =
ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
heldRoots = held,
privateChannels = entry.privateChannels,
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
)
sendMyPublicAndPrivateOutbox(concordChannelList.follow(next))
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
}
/**
* Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for
* each joined community, look for our new root among the kind-3303 wraps seen at
* our next base-rekey address. If a role-authorized rotator (owner or a current
* BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session
* rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap
* never re-triggers. Called on every Concord revision tick.
*/
private suspend fun drainConcordRekeys() {
if (!isWriteable()) return
for (session in concordSessions.sessions()) {
val wraps = session.pendingBaseRekeyWraps()
if (wraps.isEmpty()) continue
val entry = session.entry
val received =
ConcordActions.openBaseRekey(
wraps = wraps,
baseRekey = session.nextBaseRekeyKey(),
recipientSigner = signer,
priorRoot = entry.root.hexToByteArray(),
rootEpoch = entry.rootEpoch,
) ?: continue
if (received.newEpoch <= entry.rootEpoch) continue
val authority = session.state.value?.authority ?: continue
val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN)
if (!authorized) continue
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
}
}
/**
* Replace the community metadata (name / icon / description / relays) with a new
* Control-Plane edition. Honored on fold only when this account holds
* MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition.
*/
suspend fun editConcordMetadata(
communityId: String,
name: String,
description: String?,
icon: ImagePointer?,
banner: ImagePointer?,
relays: List<String>,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/**
* Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold
* only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on
* the same predicate. The channel id is a fresh random 32-byte entity id.
*/
suspend fun createConcordChannel(
communityId: String,
name: String,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */
suspend fun renameConcordChannel(
communityId: String,
channelIdHex: String,
name: String,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */
suspend fun deleteConcordChannel(
communityId: String,
channelIdHex: String,
name: String,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim(), deleted = true)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/**
* Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from
* the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT
* joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a
* card can show what the link opens, or null if the link is invalid/unreadable.
*/
suspend fun peekConcordInvite(url: String): CommunityInvite? {
val parsed = ConcordActions.parseInviteLink(url) ?: return null
val relays =
(parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet()
if (relays.isEmpty()) return null
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
}
/**
* Bootstrap the Concord hub from the network: fetch this account's kind-13302
* joined-communities list and fold the newest into [LocalCache], so communities
* we joined on another Concord client with this key surface here.
*
* We query a wide relay set because different Concord clients publish this
* private list to different places: the reference clients (Armada/Vector) push
* it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may
* also have copied it onto their **own** outbox/read relays. Our normal account
* subscription never asks for kind 13302, so without this explicit fetch a
* community joined on Armada would never appear — even if the list sits on the
* user's own outbox.
*
* Read-only import: kind 13302 is replaceable, so folding an older copy is a
* no-op and this is safe to call on every hub open. Merging our own edits with
* a foreign writer's is a separate concern (newest-wins replaceable).
*/
suspend fun importConcordCommunities() {
val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value).toSet()
if (relays.isEmpty()) return
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey))
// Stock relays like relay.ditto.pub can be slow (~1020s to first response), so give
// the fetch a generous window to drain every relay before we pick the newest copy.
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L)
val newest = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
val entryCount = newest?.let { runCatching { it.decrypt(signer).size }.getOrElse { -1 } } ?: 0
Log.d(
"Concord",
"importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " +
"newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}",
)
newest?.let { cache.justConsumeMyOwnEvent(it) }
}
// ── NIP-29 relay-group actions ───────────────────────────────────────────
// All group commands are published ONLY to the group's host relay, where
// relay29 authorizes them. The relay is the source of truth; the kind-10009
@@ -4005,7 +4820,27 @@ class Account(
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
}
/**
* True if [note] is a Concord channel message whose author is banned in that
* community's current fold. Bans are per-community (not global mutes), so they
* are enforced here at read time — the same "filter, don't delete" approach the
* rest of the app uses. A ban that arrives after a message is applied on the
* next feed pass.
*/
private fun isConcordBanned(note: Note): Boolean {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return false
val authority =
concordSessions
.sessionFor(channel.channelId.communityId)
?.state
?.value
?.authority ?: return false
return authority.isBanned(author)
}
override fun isAcceptable(note: Note): Boolean {
if (isConcordBanned(note)) return false
val mutedThreads = hiddenUsers.flow.value.mutedThreads
if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false
return note.author?.let { isAcceptable(it) } ?: true &&
@@ -4321,6 +5156,20 @@ class Account(
}
}
// Keep Concord channel metadata (community name/icon, membership) live across the whole
// app — not just the hub screen — so the Messages tab renders each channel's community
// chip, and per-community bans apply, as soon as a Control Plane folds. The revision now
// bumps only on *structural* change (a fold / membership / rekey, never a plain message),
// so this fires rarely; sample() stays as a cheap coalescer for a burst of folds.
scope.launch {
@OptIn(kotlinx.coroutines.FlowPreview::class)
concordSessions.revision.sample(500).collect {
refreshConcordChannelIndex()
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
}
}
scope.launch {
cache.antiSpam.flowSpam.collect {
it.cache.spamMessages.snapshot().values.forEach { spammer ->
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
@@ -36,6 +38,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -248,6 +251,7 @@ class AccountSettings(
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupRelayGroupList: SimpleGroupListEvent? = null,
var backupConcordList: ConcordCommunityListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
@@ -276,6 +280,7 @@ class AccountSettings(
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = MutableStateFlow(ConcordViewMode.DEFAULT),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
@@ -283,6 +288,7 @@ class AccountSettings(
val relayAuthTrustMessageStrangers: MutableStateFlow<Boolean> = MutableStateFlow(false),
) : EphemeralChatRepository,
RelayGroupRepository,
ConcordListRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
@@ -304,6 +310,13 @@ class AccountSettings(
}
}
fun updateConcordViewMode(mode: ConcordViewMode) {
if (concordViewMode.value != mode) {
concordViewMode.tryEmit(mode)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -1277,6 +1290,19 @@ class AccountSettings(
}
}
override fun concordList() = backupConcordList
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
// The joined list lives entirely in NIP-44-encrypted content (secrets),
// so an empty `tags` is NOT an empty list — guard only on null.
if (newConcordList == null) return
if (backupConcordList?.id != newConcordList.id) {
backupConcordList = newConcordList
saveAccountSettings()
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -48,6 +49,8 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -355,6 +358,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
val relayGroupChannels = LargeCache<GroupId, RelayGroupChannel>()
val concordChannels = LargeCache<ConcordChannelId, ConcordChannel>()
val paymentTracker = NwcPaymentTracker()
@@ -723,6 +727,62 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key)
fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) }
/**
* Lands a decrypted Concord chat rumor in the cache as a real Note and, for
* message-like kinds, attaches it to its channel so the shared chat feed and
* the Messages inbox render it (with previews, threading, OTS, reactions/zaps
* reusing the same id-keyed machinery as every other chat). Reactions (kind 7),
* deletes (kind 5), etc. are consumed too they wire to their target Note by
* `e`-tag through [justConsume] but are not themselves added as channel rows.
*
* Fed by [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager]
* once a wrap decrypts + validates against the folded Control Plane.
*/
fun consumeConcordRumor(
communityId: String,
channelIdHex: String,
rumor: Event,
) {
// Attach to the channel BEFORE justConsume sets the event and notifies feeds,
// so the note already carries its ConcordChannel gatherer when it flows through
// the Messages-list incremental filter (which routes rows by that gatherer).
val messageRow =
if (rumor is ChatEvent || rumor is CommentEvent) {
val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
val note = getOrCreateNote(rumor.id)
// Skip attaching a row for a message we already know is deleted (its kind-5 delete
// was processed first). Otherwise every reproject — which re-emits the whole wrap
// buffer — would re-add then re-remove it, churning the feed. justConsume still
// records the (already-known) deletion below; a delete arriving LATER is handled by
// the normal deletion cascade unlinking the note from its gatherers.
if (!deletionIndex.hasBeenDeleted(rumor)) ch.addNote(note)
ch to note
} else {
null
}
// wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature
// check would fail and the event would never load onto its Note — leaving the chat row
// stuck on the "loading / not found" placeholder. Its authenticity is already established
// by the envelope open path (ConcordStreamEnvelope.open verifies the seal signature,
// binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59
// gift-wrapped DM rumor, so we consume it as pre-verified.
justConsume(rumor, null, true)
// justConsume bails without loading the event when the rumor has already been deleted
// (a kind-5 delete referencing it was processed first — easy to hit in Concord because a
// reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to
// verify. We attached the row up front, so an unpopulated note would otherwise linger as a
// permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message)
// is already handled by the normal deletion cascade unlinking the note from its gatherers.
messageRow?.let { (ch, note) ->
if (note.event == null) ch.removeNote(note)
}
}
fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? {
if (isValidHex(key)) {
return getOrCreatePublicChatChannel(key)
@@ -3757,6 +3817,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
// Concord private joined-communities list (kind 13302). Replaceable, self-encrypted;
// ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")),
// so — exactly like the 10009 list above — it must be stored replaceably or the Concord
// hub stays empty even after the event arrives.
is ConcordCommunityListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is GroupMetadataEvent -> {
consume(event, relay, wasVerified)
}
@@ -26,12 +26,18 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ConcurrentHashMap
class ScreenAuthAccount(
val account: Account,
@@ -49,7 +55,14 @@ class AuthCoordinator(
RelayAuthenticator(
client,
scope,
signWithAllLoggedInUsers = { relayUrl, authTemplate ->
signWithAllLoggedInUsers = { relayUrl, authTemplate, interactive ->
// Concord plane traffic is gated behind NIP-42 as the derived *stream key*, not the
// user: a relay serves a plane's kind-1059 wraps only to a connection authenticated
// as that stream key. These AUTHs expose no user identity (ephemeral derived keys)
// and are signed locally, so we always attach them — independent of the per-account
// policy below — or Concord channels/messages never load. No-op for non-Concord relays.
val streamAuths = signConcordStreamAuths(relayUrl, authTemplate)
// Reconstruct *why* this relay wants auth from what the shared client is doing with
// it. Built lazily so accounts that fail the first-party gate below don't pay for it.
val context by
@@ -63,7 +76,6 @@ class AuthCoordinator(
),
)
}
// One socket is shared by every logged-in account, so an AUTH challenge is not tied
// to any single one of them. We answer PER ACCOUNT: an account only reveals its
// identity to a relay it has a first-party reason to be on (its own inbox/outbox
@@ -86,8 +98,24 @@ class AuthCoordinator(
RelayAuthVerdict.DENY -> false
RelayAuthVerdict.ASK -> {
// Prompt at most once per challenge; reuse the answer for any other
// account that also reaches ASK on this same relay.
val choice = askChoice ?: promptBus.requestDecision(relayUrl, context.purposes).also { askChoice = it }
// account that also reaches ASK on this same relay. But never block the
// derived stream-key AUTH behind that dialog: on a relay that hosts our
// Concord planes we DISMISS the user-auth ASK (skip account auth) so the
// stream AUTHs return immediately instead of waiting on a prompt.
//
// A non-[interactive] pass is an automatic re-auth off an `auth-required:`
// CLOSED (e.g. a Concord channel-plane REQ refused because the connection
// AUTHed before the control plane folded in its channel stream keys). It
// must never raise a fresh dialog: DISMISS the account ASK and let only the
// already-approved identities (ledger-ALLOW accounts + stream keys) re-send.
val choice =
askChoice ?: (
if (streamAuths.isNotEmpty() || !interactive) {
UserAuthChoice.DISMISS
} else {
promptBus.requestDecision(relayUrl, context.purposes)
}
).also { askChoice = it }
when (choice) {
UserAuthChoice.ALLOW_ONCE -> true
UserAuthChoice.ALWAYS_ALLOW -> {
@@ -114,10 +142,36 @@ class AuthCoordinator(
}
}
signed
signed + streamAuths
},
)
/**
* Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every
* watched account. Signed locally from the derived stream secret (a raw [KeyPair] via
* [NostrSignerSync]) never the account signer, and never surfacing the user's identity.
*/
private suspend fun signConcordStreamAuths(
relayUrl: NormalizedRelayUrl,
authTemplate: EventTemplate<RelayAuthEvent>,
): List<RelayAuthEvent> {
val secrets = authWithAccounts.distinct().flatMap { it.concordSessions.streamAuthSecretsFor(relayUrl) }
if (secrets.isEmpty()) return emptyList()
return secrets.mapNotNull { secret ->
try {
// Cache the signer by secret so we don't re-derive the secp256k1 keypair for every
// plane on every relay challenge/reconnect.
streamSigners.getOrPut(secret.toHexKey()) { NostrSignerSync(KeyPair(privKey = secret)) }.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e)
null
}
}
}
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
/**
* True when [account] has a first-party reason to authenticate with [relayUrl] on the shared
* client: it is publishing its own event there, a subscription there is reading its own
@@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFil
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler
@@ -128,6 +130,15 @@ class RelaySubscriptionsCoordinator(
val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab
val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed
// Concord Channels (encrypted communities). One assembler keeps every joined community's
// control + channel planes live (kind-1059 by derived stream address).
val concordChannels = ConcordChannelFilterAssembler(client)
// On-demand backward history pager for whichever Concord Channel screen is open (older wraps by
// until+limit, per relay), the Concord analog of the per-conversation NIP-04 history.
val concordChannelHistory = ConcordChannelHistoryFilterAssembler(client)
val chatroom = ChatroomFilterAssembler(client)
val community = CommunityFilterAssembler(client)
val gitRepository = RepositoryFilterAssembler(client)
@@ -194,6 +205,8 @@ class RelaySubscriptionsCoordinator(
relayGroupThreadFeed,
relayGroupWarmup,
relayGroupsDiscovery,
concordChannels,
concordChannelHistory,
account,
accountForeground,
home,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metada
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -85,6 +86,12 @@ val AccountInfoAndListsFromKeyKinds2 =
// Loaded up-front so "My Groups" and group memberships resolve immediately at login,
// without waiting for the groups screen to mount its own subscription.
SimpleGroupListEvent.KIND,
// Concord private joined-communities list (kind 13302, CORD-05): the self-encrypted
// entries carrying each community's secrets. Loaded up-front for the same reason as
// the NIP-29 list above — so communities joined on another device or client (e.g. the
// Armada reference client, sharing this key) surface in the Concord hub at login,
// instead of only appearing after creating/redeeming an invite in Amethyst itself.
ConcordCommunityListEvent.KIND,
// NIP-60 Cashu wallet + NIP-61 nutzap info. Replaceables, always
// useful to have available — wallet event holds the user's P2PK key
// + mint list, nutzap info tells other clients which mints to lock
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
@@ -214,6 +215,38 @@ fun observeNoteReplyCount(
return flow.collectAsStateWithLifecycle(note.replies.size)
}
/**
* Count of a chat message's **minichat** replies its kind-1111 [CommentEvent]
* children only (inline quote-replies are ordinary kind-9/42 messages and are not
* counted here). Drives the "N replies" chip that opens the minichat.
*
* Mounting this registers the message with [EventFinderFilterAssemblerSubscription], which
* batches the visible messages' ids into shared REQs for their replies (kind-1111 among
* them) so for public chats (NIP-28/NIP-29) the thread replies load, and the chip appears,
* just by rendering the rows. Concord's kind-1111 replies instead arrive over the channel
* plane, so that REQ finds nothing there and is a harmless no-op.
*/
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteMinichatReplyCount(
note: Note,
accountViewModel: AccountViewModel,
): State<Int> {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
val flow =
remember(note) {
note
.flow()
.replies.stateFlow
.sample(200)
.mapLatest { it.note.replies.count { reply -> reply.event is CommentEvent } }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.count { it.event is CommentEvent })
}
@Composable
fun observeNoteReactions(
note: Note,
@@ -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
}
@@ -0,0 +1,74 @@
/*
* 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.combinedClickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import kotlinx.coroutines.launch
/**
* Renders a Concord invite link (`/invite/<naddr>#<fragment>`) inline as a
* tappable link that opens the redeem flow ([Route.ConcordInvite], which fetches +
* unlocks the bundle and joins). Long-press copies the full link. Falls back to
* plain text if the literal can't be parsed (detection should guarantee it does).
*/
@Composable
fun ClickableConcordInviteLink(
linkText: String,
nav: INav,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) }
if (parsed == null) {
Text(text = linkText)
return
}
val clickableModifier =
remember(linkText) {
Modifier.combinedClickable(
onLongClick = { scope.launch { clipboardManager.setText(linkText) } },
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
)
}
Text(
text = linkText,
modifier = clickableModifier,
color = MaterialTheme.colorScheme.primary,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
}
@@ -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
@@ -66,6 +67,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.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -218,6 +221,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 == ConcordInviteBundleEvent.KIND) {
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,
)
}
}
}
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
@@ -534,6 +535,7 @@ private fun RenderWordWithoutPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav)
is ConcordInviteLinkSegment -> ClickableConcordInviteLink(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
@@ -574,6 +576,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)
}
@@ -149,7 +149,16 @@ fun RobohashFallbackAsyncImage(
val resources = LocalContext.current.resources
SubcomposeAsyncImage(
model = ProfilePictureUrl(bridgedModel),
// The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only
// NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at
// file://) would fail there. Route only remote http(s) pictures through the thumbnail
// cache; hand local/content URIs to Coil's native fetchers, which load them directly.
model =
if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) {
ProfilePictureUrl(bridgedModel)
} else {
bridgedModel
},
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
@@ -107,9 +107,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroup
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat.MinichatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
@@ -637,6 +645,61 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.Concord> {
ConcordChannelScreen(
communityId = it.communityId,
channelId = it.channelId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ChatMinichat> {
MinichatScreen(
rootId = it.rootId,
concordCommunityId = it.concordCommunityId,
concordChannelId = it.concordChannelId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordServer> {
ConcordChannelListScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordMembers> {
ConcordMembersScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordEdit> {
ConcordEditScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordInvite> {
ConcordInviteScreen(
link = it.link,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEnd<Route.Concords> { ConcordHomeScreen(accountViewModel, nav) }
composableFromEnd<Route.ConcordCreate> { ConcordCreateScreen(accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupMembers> {
RelayGroupMembersScreen(
id = it.id,
@@ -67,6 +67,7 @@ enum class NavBarItem {
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
CONCORD,
FOLLOW_PACKS,
LIVE_STREAMS,
NESTS,
@@ -326,6 +327,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.CONCORD to
NavBarItemDef(
id = NavBarItem.CONCORD,
labelRes = R.string.concord_home_title,
icon = MaterialSymbols.Group,
resolveRoute = { Route.Concords },
),
NavBarItem.FOLLOW_PACKS to
NavBarItemDef(
id = NavBarItem.FOLLOW_PACKS,
@@ -448,6 +456,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.routes
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
@@ -65,10 +66,42 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
/**
* A minichat reply a kind-1111 [CommentEvent] posted into a chat message's thread should open
* that message's minichat ([Route.ChatMinichat]), not the whole channel it lives in. Regular chat
* messages are kind 9 / 42, so a [CommentEvent] in a *chat context* is always a thread reply. We
* gate on the chat context (the reply is attached to a Concord / relay-group / public-chat gatherer,
* or its root message is) precisely so a generic NIP-22 comment on an article or note keeps its own
* thread route and isn't mistaken for a minichat. Returns null when it isn't a chat-context comment.
*/
fun minichatRouteFor(note: Note): Route? {
val comment = note.event as? CommentEvent ?: return null
val rootId = comment.rootEventIds().firstOrNull() ?: return null
// Prefer the reply's own Concord channel (the reply arrived over that plane, so its gatherer
// always carries the community/channel), else the root note's if it happens to be loaded. Passing
// these lets the minichat screen resolve the plane + relays even when the parent isn't cached.
val concord =
note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }
?: LocalCache.getNoteIfExists(rootId)?.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }
if (concord != null) {
return Route.ChatMinichat(rootId, concord.channelId.communityId, concord.channelId.channelId)
}
val inChatContext = note.isInChatGatherer() || LocalCache.getNoteIfExists(rootId)?.isInChatGatherer() == true
return if (inChatContext) Route.ChatMinichat(rootId) else null
}
private fun Note.isInChatGatherer(): Boolean = inGatherers?.any { it is ConcordChannel || it is RelayGroupChannel || it is PublicChatChannel } == true
fun routeFor(
note: Note,
loggedIn: Account,
): Route? {
// A minichat reply opens the message's thread, not the whole channel it belongs to. Must run
// before the channel-gatherer shortcuts below, which would otherwise swallow it into the channel.
minichatRouteFor(note)?.let { return it }
// Marmot group messages should navigate to the group chat
val marmotGroup = note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
if (marmotGroup != null) {
@@ -84,6 +117,15 @@ fun routeFor(
return routeFor(relayGroup)
}
// Concord channel content (kind 9 chat, 1111 reply, 7 reaction) lands in LocalCache as a real
// Note attached to its ConcordChannel gatherer. Route to the Concord chat instead of the generic
// thread view it would otherwise fall through to: a minichat reply (kind-1111) opens its thread
// ([minichatRouteFor]); a top-level message / reaction opens the channel.
val concordChannel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }
if (concordChannel != null) {
return minichatRouteFor(note) ?: routeFor(concordChannel)
}
val noteEvent = note.event ?: return Route.EventRedirect(note.idHex)
if (noteEvent.isGroupScoped()) {
@@ -282,6 +324,8 @@ fun routeFor(note: RelayGroupChannel): Route = Route.RelayGroup(note.groupId.id,
fun routeFor(groupId: GroupId): Route = Route.RelayGroup(groupId.id, groupId.relayUrl.url)
fun routeFor(channel: ConcordChannel): Route = Route.Concord(channel.channelId.communityId, channel.channelId.channelId)
fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex)
@@ -301,6 +345,17 @@ fun routeReplyTo(
return Route.MarmotGroupChat(marmotGroup.nostrGroupId, replyId = note.idHex)
}
// Concord messages are end-to-end encrypted: a reply must go through the channel plane (a sealed
// kind-1111), never a public kind-1111 — which would leak the private rumor id onto public relays
// and wouldn't bind to the channel. Route the reply into the message's minichat, whose composer
// sends the wrapped reply. For a thread reply (kind-1111) reply into the same flat thread (its
// root); for a top-level message (kind-9) the message itself is the thread root.
val concord = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }
if (concord != null) {
val rootId = (note.event as? CommentEvent)?.rootEventIds()?.firstOrNull() ?: note.idHex
return Route.ChatMinichat(rootId, concord.channelId.communityId, concord.channelId.channelId)
}
val noteEvent = note.event
return when (noteEvent) {
is ChannelMessageEvent -> {
@@ -678,6 +678,49 @@ sealed class Route {
@Serializable object RelayGroupBrowse : Route()
// Concord Channels (encrypted communities). Addressed by community id + channel id
// (both lowercase hex), never a host relay — a channel plane may be mirrored on all
// of the community's relays.
@Serializable data class Concord(
val communityId: String,
val channelId: String,
val draftId: HexKey? = null,
val replyTo: HexKey? = null,
) : Route()
@Serializable data class ConcordServer(
val communityId: String,
) : Route()
@Serializable data class ConcordMembers(
val communityId: String,
) : Route()
@Serializable data class ConcordEdit(
val communityId: String,
) : Route()
@Serializable object ConcordCreate : Route()
// Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow.
@Serializable data class ConcordInvite(
val link: String,
) : Route()
@Serializable object Concords : Route()
// The "minichat" of a chat message: its kind-1111 thread replies, opened from the message and
// rendered as a chat-within-a-chat. Keyed by the root message id; the screen resolves the chat
// context (Concord channel, public chat, relay group) from the note's gatherer. When opened from
// a Concord reply whose parent message may not be cached, [concordCommunityId]/[concordChannelId]
// carry the plane context (taken from the reply's channel), so the screen can still subscribe the
// plane and backfill the parent — without them, a reply to an unloaded parent can't pick the relay.
@Serializable data class ChatMinichat(
val rootId: HexKey,
val concordCommunityId: String? = null,
val concordChannelId: String? = null,
) : Route()
@Serializable data class ChannelMetadataEdit(
val id: String? = null,
) : Route()
@@ -58,6 +58,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
@@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
@@ -200,6 +202,7 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay
@@ -1452,6 +1455,7 @@ private fun RenderNoteRow(
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
accountViewModel,
nav,
@@ -1907,6 +1911,16 @@ fun FirstUserInfoRow(
PrivateRumorMark(accountViewModel)
}
// A Concord message (e.g. surfaced in Notifications) names its parent community with the same
// highlighted chip the Messages row uses, tapping through to that community's channel list.
val concordChannel = remember(baseNote) { baseNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } }
concordChannel?.communityName?.let { communityName ->
ConcordCommunityPill(
communityName = communityName,
onClick = { nav.nav(Route.ConcordServer(concordChannel.channelId.communityId)) },
)
}
if (isPinned) {
PinnedMark()
}
@@ -274,6 +274,31 @@ fun CardBody(
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
// Concord moderation: only present when this account may actually act.
val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null }
val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) }
val showConcordBanDialog = remember { mutableStateOf(false) }
if (showConcordBanDialog.value) {
QuickActionAlertDialogOneButton(
title = stringRes(R.string.concord_ban_user_title),
textContent = stringRes(R.string.concord_ban_user_body),
buttonIcon = MaterialSymbols.Gavel,
buttonText = stringRes(R.string.concord_ban_user),
buttonColors =
ButtonDefaults.buttonColors(
containerColor = LightRedColor,
contentColor = Color.White,
),
onClickDoOnce = {
accountViewModel.banConcordMember(note)
showConcordBanDialog.value = false
onDismiss()
},
onDismiss = { showConcordBanDialog.value = false },
)
}
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
@@ -449,6 +474,30 @@ fun CardBody(
showReportDialog.value = true
}
}
if (concordAdmin != null) {
VerticalDivider(color = primaryLight)
val isAdmin = concordAdmin.third
NoteQuickActionItem(
MaterialSymbols.Shield,
stringRes(if (isAdmin) R.string.concord_remove_admin else R.string.concord_make_admin),
) {
accountViewModel.toggleConcordAdmin(note)
onDismiss()
}
}
if (canConcordBan) {
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
MaterialSymbols.Gavel,
stringRes(R.string.concord_ban_user),
) {
showConcordBanDialog.value = true
}
}
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -35,10 +36,13 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
@Composable
fun RenderChat(
@@ -46,6 +50,7 @@ fun RenderChat(
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
unPackReply: ReplyRenderType,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
@@ -65,6 +70,27 @@ fun RenderChat(
overflow = TextOverflow.Ellipsis,
)
} else {
// A kind-9 chat message carries its reply target as a NIP-18 `q` (or NIP-10 `e`)
// tag, NOT as a NIP-10 thread — so it's not a BaseThreadedEvent and RenderTextEvent's
// reply-to preview never fires for it. Render the quoted parent here so a Concord/MLS
// chat reply shows what it's replying to wherever NoteCompose draws it (Notifications
// tab, feed, threads) — the chat feed has its own reply-row and passes NONE.
if (unPackReply == ReplyRenderType.FULL && !makeItShort) {
val replyingDirectlyTo =
remember(note) {
// Skip the preview when the parent is already cited inline (`nostr:...`) in the
// message — quotesLeft renders it at that spot, so a top preview would duplicate
// it. Happens with MLS/WhiteNoise quotes; Concord `q` replies aren't cited inline.
note.replyTo?.lastOrNull()?.takeUnless { parent ->
(noteEvent as? BaseNoteEvent)?.findCitations()?.contains(parent.idHex) == true
}
}
if (replyingDirectlyTo != null) {
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
}
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning(
@@ -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.
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -348,7 +349,7 @@ class AccountViewModel(
RelayAuthenticator(
newClient,
customScope,
signWithAllLoggedInUsers = { _, authTemplate ->
signWithAllLoggedInUsers = { _, authTemplate, _ ->
if (account.signer.isWriteable()) {
try {
listOf(account.signer.sign(authTemplate))
@@ -541,6 +542,14 @@ class AccountViewModel(
note: Note,
reaction: String,
) {
// Concord messages are encrypted: a public kind-7 would e-tag the private rumor id onto
// public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an
// existing Concord reaction is a follow-up; for now this only adds one.)
if (note.inGatherers?.any { it is ConcordChannel } == true) {
launchSigner { account.reactToConcordMessage(note, reaction) }
return
}
launchSigner {
val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction)
if (currentReactions.isNotEmpty()) {
@@ -582,6 +591,64 @@ class AccountViewModel(
reactToOrDelete(note, reaction)
}
/** Ban the author of a Concord channel message (no-op unless this account may ban them). */
fun banConcordMember(note: Note) {
val (communityId, member) = account.concordBanTarget(note) ?: return
launchSigner { account.banConcordMember(communityId, member) }
}
/** Toggle the Admin role on the author of a Concord channel message (owner only). */
fun toggleConcordAdmin(note: Note) {
val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return
launchSigner {
if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member)
}
}
/** Promote/demote [member] as an Admin of [communityId] (from the Members roster; owner only takes effect). */
fun setConcordAdmin(
communityId: String,
member: HexKey,
makeAdmin: Boolean,
) = launchSigner {
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
}
/** Ban/unban [member] from [communityId] (from the Members roster). */
fun setConcordBan(
communityId: String,
member: HexKey,
ban: Boolean,
) = launchSigner {
if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member)
}
/**
* Remove [member] from [communityId] absolutely (CORD-06 Refounding): rotate the
* community key so the member's key stops working for anything sent afterwards.
* Heavier than a ban (re-keys every retained member); owner / BAN-holder only.
*/
fun removeConcordMember(
communityId: String,
member: HexKey,
) = launchSigner {
account.refoundConcordCommunity(communityId, setOf(member))
}
/** Pull the account's Concord community list from the stock + own relays (Concord hub bootstrap). */
fun importConcordCommunities() =
viewModelScope.launch(Dispatchers.IO) {
account.importConcordCommunities()
}
/** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */
fun sendConcordTyping(
communityId: String,
channelIdHex: String,
) = viewModelScope.launch(Dispatchers.IO) {
account.sendConcordTyping(communityId, channelIdHex)
}
@Immutable
data class NoteComposeReportState(
val isPostHidden: Boolean = false,
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
@@ -134,6 +135,8 @@ private fun PreloadFor(
NavBarItem.RELAY_GROUPS -> RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
@@ -294,6 +295,22 @@ class GiftWrapEventHandler(
eventNote: Note,
publicNote: Note,
) {
// Concord plane wraps are kind-1059 too, but their `p` tag is ephemeral and
// the payload opens with a derived plane key, not our identity — so route
// them to the Concord read-path first. A recognized wrap is fully handled
// there (folded / re-projected) and must not fall through to the DM path.
if (account.concordSessions.ingest(event)) {
// Concord typing heartbeats ride kind-21059 ephemeral wraps that arrive
// continuously while anyone in any joined community is composing. NIP-01
// ephemeral events (2000029999) must never be persisted; the session has
// already folded the state they carried, so drop the durable wrap note now
// to keep LocalCache from growing without bound.
if (event is EphemeralGiftWrapEvent) {
cache.unlinkAndRemove(listOf(eventNote))
}
return
}
if (event.recipientPubKey() != account.signer.pubKey) return
val innerGiftId = event.innerEventId
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -89,6 +90,11 @@ fun LoggedInPage(
// Loads account information + DMs and Notifications from Relays.
AccountFilterAssemblerSubscription(accountViewModel)
// Preloads every joined Concord community's Control planes app-wide (their wraps are addressed to
// derived stream keys, so the always-on DM tail can't pick them up) — so communities fold, and
// their channels/metadata/icon appear, without waiting for a Concord screen to be opened.
ConcordChannelPreload(accountViewModel)
// Foreground-only loaders: follows-outbox finder + random-relay notifications.
// Pauses on ON_STOP, resumes on ON_START.
AccountForegroundFilterAssemblerSubscription(accountViewModel)
@@ -21,17 +21,24 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -42,8 +49,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount
import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -96,6 +110,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as MaterialSymbolIcon
@Composable
fun ChatroomMessageCompose(
@@ -280,6 +295,8 @@ fun NormalChatNote(
ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
MinichatReplyChip(note, accountViewModel, nav)
val geo = remember(note) { note.event?.geoHashOrScope() }
if (geo != null) {
Spacer(StdHorzSpacer)
@@ -384,6 +401,63 @@ private fun MessageBubbleLines(
}
}
/**
* A chip on a chat message's action row showing how many kind-1111 thread ("minichat")
* replies it has; tapping opens that thread. Shown only when there is at least one an
* inline reply is an ordinary message and isn't counted.
*
* Only shown where minichats are actually wired: the public chats (Concord, NIP-28, NIP-29).
* NIP-17 DMs are deliberately excluded most clients don't render kind-1111 replies in a DM
* view, so a thread there would be a dead end.
*/
@Composable
private fun MinichatReplyChip(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val supportsMinichat =
remember(note) {
note.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true
}
if (!supportsMinichat) return
val count by observeNoteMinichatReplyCount(note, accountViewModel)
if (count > 0) {
Spacer(StdHorzSpacer)
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.clickable { nav.nav(Route.ChatMinichat(note.idHex)) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
MaterialSymbolIcon(
symbol = MaterialSymbols.Forum,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(13.dp),
)
Text(
text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}
/**
* Id of a note whose reply-to preview should be suppressed on a message row. Set by a
* thread view that already pins that note at the top (the minichat pins its root), so each
* reply doesn't redundantly re-render the same parent as an inner quote. Null everywhere else.
*/
val LocalSuppressReplyToNoteId = compositionLocalOf<String?> { null }
@Composable
fun RenderReplyRow(
note: Note,
@@ -396,7 +470,8 @@ fun RenderReplyRow(
onScrollToNote: ((Note) -> Unit)? = null,
) {
val replyTo = note.replyTo?.lastOrNull()
if (!innerQuote && replyTo != null && !isCitedInContent(note, replyTo)) {
val suppressId = LocalSuppressReplyToNoteId.current
if (!innerQuote && replyTo != null && replyTo.idHex != suppressId && !isCitedInContent(note, replyTo)) {
RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote)
}
}
@@ -0,0 +1,70 @@
/*
* 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.minichat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
/**
* The reactive read-model of a message's minichat: the root's kind-1111 [CommentEvent]
* thread replies, oldest-first, filtered by the account's block/ban rules.
*
* Driven off the root [Note]'s replies flow, so it recomputes whenever a reply arrives
* from the plane (Concord), a relay subscription (public chats), or the local echo of the
* user's own send. Wherever the minichat is opened from, the list stays live.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MinichatFeedViewModel(
val rootNote: Note,
val account: Account,
) : ViewModel() {
val replies: StateFlow<List<Note>> =
rootNote
.flow()
.replies.stateFlow
.mapLatest { collectReplies() }
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.Eagerly, collectReplies())
private fun collectReplies(): List<Note> =
rootNote.replies
.filter { it.event is CommentEvent && account.isAcceptable(it) }
.sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
class Factory(
val rootNote: Note,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = MinichatFeedViewModel(rootNote, account) as T
}
}
@@ -0,0 +1,252 @@
/*
* 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.minichat
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The minichat ("chat within a chat") of a single chat message: the message pinned at
* top, then its kind-1111 thread replies as chat bubbles, with a composer that always
* posts a kind-1111 rooted at that message (flat replying inside a minichat doesn't
* spawn sub-threads). Opened from the "N replies" chip on any chat message.
*
* Self-sufficient regardless of where it's opened from: it mounts its own datasource so
* replies keep flowing (the Concord plane subscription when the root is a Concord message;
* a relay reply subscription for public chats), and drives the list from a reactive
* [MinichatFeedViewModel] so new replies appear (and auto-scroll into view) live.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MinichatScreen(
rootId: String,
concordCommunityId: String? = null,
concordChannelId: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) }
// Resolve the Concord channel from the (loaded) root note's gatherer, else from the ids threaded
// in by the reply that opened this minichat. The latter is what lets a reply-to-an-unloaded-parent
// still pick the plane + relays: the reply arrived over the channel plane, so its channel id is known.
val concordChannel = remember(rootNote) { rootNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } }
val communityId = concordCommunityId ?: concordChannel?.channelId?.communityId
val channelId = concordChannelId ?: concordChannel?.channelId?.channelId
val isConcord = communityId != null && channelId != null
// Datasource: keep the thread replies flowing no matter the entry point. Concord replies arrive
// over the channel plane, so mount the plane subscription plus this channel's backward-history
// pager and page it until the parent message loads (see [ConcordMinichatBackfillUntilRoot]);
// public-chat (NIP-28/NIP-29) replies come over a relay REQ for this message's kind-1111 children.
if (isConcord) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
ConcordChannelHistorySubscription(communityId!!, channelId!!, accountViewModel.dataSources().concordChannelHistory, accountViewModel)
ConcordMinichatBackfillUntilRoot(rootNote, accountViewModel.dataSources().concordChannelHistory.history)
}
EventFinderFilterAssemblerSubscription(rootNote, accountViewModel)
val feedViewModel: MinichatFeedViewModel =
viewModel(
key = rootId + "MinichatFeedViewModel",
factory = MinichatFeedViewModel.Factory(rootNote, accountViewModel.account),
)
val replies by feedViewModel.replies.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
// Auto-scroll to the newest reply as they arrive (root is item 0, replies follow).
LaunchedEffect(replies.size) {
if (replies.isNotEmpty()) listState.animateScrollToItem(replies.size)
}
val composer = remember { TextFieldState() }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val canPost by remember { derivedStateOf { composer.text.isNotBlank() } }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.chat_minichat_title), maxLines = 1) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
actions = {
// For a Concord thread, always offer a jump to the full channel — the "chat room
// itself", where the whole timeline loads and (if you aren't a member yet) you can
// join. This is the way out when the pinned root is still backfilling or unavailable.
if (isConcord) {
IconButton(onClick = { nav.nav(Route.Concord(communityId!!, channelId!!)) }) {
SymbolIcon(symbol = MaterialSymbols.Forum, contentDescription = stringRes(R.string.concord_open_channel))
}
}
},
)
},
) { padding ->
Column(Modifier.fillMaxHeight().padding(padding)) {
// Every reply here is rooted at [rootNote], which is already pinned at the top — so
// suppress the redundant reply-to-root preview each reply would otherwise render.
CompositionLocalProvider(LocalSuppressReplyToNoteId provides rootId) {
LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().weight(1f, true)) {
item("root") {
ChatroomMessageCompose(
baseNote = rootNote,
routeForLastRead = null,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {},
onWantsToEditDraft = {},
)
HorizontalDivider()
}
items(replies, key = { it.idHex }) { reply ->
ChatroomMessageCompose(
baseNote = reply,
routeForLastRead = null,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {},
onWantsToEditDraft = {},
)
}
}
}
Column(modifier = EditFieldModifier) {
ThinPaddingTextField(
state = composer,
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
Text(
text = stringRes(R.string.reply_here),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
ThinSendButton(
isActive = canPost,
modifier = EditFieldTrailingIconModifier,
) {
val text = composer.text.toString().trim()
if (text.isNotEmpty()) {
composer.clearText()
scope.launch(Dispatchers.IO) {
try {
accountViewModel.account.sendMinichatReply(rootNote, text)
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
}
}
}
/**
* Pages the Concord channel's backward history until the minichat's parent message loads (or the
* relays are exhausted). Reaching a minichat from a reply notification can land on a parent that
* isn't in the live tail; the reply itself pinned the channel context, so we can walk the plane
* history back to fetch the parent after which its whole kind-1111 thread projects normally. The
* `!loading` gate serializes pages; once the root's event is present, or nothing is left, it latches off.
*/
@Composable
private fun ConcordMinichatBackfillUntilRoot(
rootNote: Note,
history: ConcordChannelHistorySubAssembler,
) {
LaunchedEffect(rootNote, history) {
combine(history.loadingMore, history.status) { loading, status ->
rootNote.event == null && !loading && !status.exhausted
}.distinctUntilChanged()
.filter { it }
.collect { history.advanceAll() }
}
}
@@ -0,0 +1,423 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
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.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The channel list of one Concord community (the "server" view). Reads the folded
* Control Plane from the community session and renders one row per channel; tapping
* opens that channel's [ConcordChannelScreen].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordChannelListScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
val account = accountViewModel.account
// Re-resolve on each revision so a deep link that lands before the session exists picks it up.
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) }
val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) })
.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var inviteLink by remember { mutableStateOf<String?>(null) }
var minting by remember { mutableStateOf(false) }
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
val canManageChannels =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
} == true
// channelIdHex == null → create; else → rename that channel.
var channelEditor by remember { mutableStateOf<ConcordChannelEditor?>(null) }
var channelToDelete by remember { mutableStateOf<ConcordChannelEditor?>(null) }
inviteLink?.let { link ->
InviteLinkDialog(link = link, onDismiss = { inviteLink = null })
}
channelEditor?.let { editor ->
ConcordChannelEditDialog(
initialName = editor.initialName,
isCreate = editor.channelIdHex == null,
onDismiss = { channelEditor = null },
onConfirm = { newName ->
channelEditor = null
scope.launch {
if (editor.channelIdHex == null) {
account.createConcordChannel(communityId, newName)
} else {
account.renameConcordChannel(communityId, editor.channelIdHex, newName)
}
}
},
)
}
channelToDelete?.let { target ->
val id = target.channelIdHex ?: return@let
AlertDialog(
onDismissRequest = { channelToDelete = null },
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_title)) },
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_message, target.initialName)) },
confirmButton = {
TextButton(onClick = {
channelToDelete = null
scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) }
}) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm))
}
},
dismissButton = {
TextButton(onClick = { channelToDelete = null }) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
// Prefer the folded metadata name, then the stored community name from the list
// entry (always present from the join/create — this is what shows everywhere else).
// Fall back to the app name only if neither exists (should be unreachable), never as
// the normal "metadata hasn't folded yet" placeholder — that showed "Amy Debug".
val title =
state?.metadata?.name
?: session?.entry?.name?.ifBlank { null }
?: stringRes(com.vitorpamplona.amethyst.R.string.app_name)
Text(title, maxLines = 1)
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
},
actions = {
val canEdit =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
} == true
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title))
}
if (canEdit) {
IconButton(onClick = { nav.nav(Route.ConcordEdit(communityId)) }) {
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
}
}
IconButton(
enabled = !minting,
onClick = {
minting = true
scope.launch {
try {
inviteLink = account.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
minting = false
}
}
},
) {
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
}
},
)
},
floatingActionButton = {
if (canManageChannels) {
FloatingActionButton(
onClick = { channelEditor = ConcordChannelEditor(channelIdHex = null, initialName = "") },
shape = CircleShape,
) {
SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_create))
}
}
},
) { padding ->
val channels =
state
?.channels
?.entries
?.toList()
.orEmpty()
if (channels.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(channels, key = { it.key }) { entry ->
val def = entry.value.definition
val name = def?.name ?: entry.key
val icon =
when {
def?.voice == true -> MaterialSymbols.Mic
def?.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
}
Row(
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SymbolIcon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1)
if (canManageChannels) {
ConcordChannelRowMenu(
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
)
}
}
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
/** A pending channel create ([channelIdHex] null) or rename target. */
private data class ConcordChannelEditor(
val channelIdHex: String?,
val initialName: String,
)
/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */
@Composable
private fun ConcordChannelRowMenu(
onRename: () -> Unit,
onDelete: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
SymbolIcon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_rename)) },
onClick = {
expanded = false
onRename()
},
)
DropdownMenuItem(
text = {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
expanded = false
onDelete()
},
)
}
}
}
/** Name-entry dialog for creating a new channel or renaming an existing one. */
@Composable
private fun ConcordChannelEditDialog(
initialName: String,
isCreate: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringRes(
if (isCreate) {
com.vitorpamplona.amethyst.R.string.concord_channel_create
} else {
com.vitorpamplona.amethyst.R.string.concord_channel_rename
},
),
)
},
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_name_label)) },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
enabled = name.isNotBlank(),
onClick = { if (name.isNotBlank()) onConfirm(name.trim()) },
) {
Text(
stringRes(
if (isCreate) {
com.vitorpamplona.amethyst.R.string.concord_channel_create
} else {
com.vitorpamplona.amethyst.R.string.concord_channel_rename_save
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
},
)
}
/** Shows a freshly minted invite link as a QR code with copy + share actions. */
@Composable
private fun InviteLinkDialog(
link: String,
onDismiss: () -> Unit,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_title)) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
QrCodeDrawer(contents = link, modifier = Modifier.size(220.dp))
Text(
text = link,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 12.dp),
)
}
},
confirmButton = {
TextButton(onClick = {
val send =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, link)
}
context.startActivity(Intent.createChooser(send, stringRes(context, com.vitorpamplona.amethyst.R.string.concord_invite_title)))
onDismiss()
}) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.quick_action_share))
}
},
dismissButton = {
TextButton(onClick = {
scope.launch { clipboard.setText(link) }
onDismiss()
}) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.copy_to_clipboard))
}
},
)
}
@@ -0,0 +1,570 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The chat screen of one Concord Channel. Messages are real Notes in [LocalCache]
* attached to the channel, so the feed reuses the shared [RefreshingChatroomFeedView]
* (avatars, reactions, replies, zaps, OTS) and the composer reuses the same
* @-mention / inline-mention / reply machinery as the other chats. Only the send
* path is Concord-specific: it wraps the message on the channel plane.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordChannelScreen(
communityId: String,
channelId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
// Load the user's custom-emoji packs so the `:shortcode:` composer autocomplete has entries.
WatchAndLoadMyEmojiList(accountViewModel)
ConcordChannelHistorySubscription(communityId, channelId, accountViewModel.dataSources().concordChannelHistory, accountViewModel)
val account = accountViewModel.account
val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) }
val feedViewModel: ChannelFeedViewModel =
viewModel(
key = channel.channelId.toKey() + "ConcordFeedViewModel",
factory = ChannelFeedViewModel.Factory(channel, account),
)
WatchLifecycleAndUpdateModel(feedViewModel)
// Backward history pager for this open channel (older wraps by until+limit, per relay), mirroring
// the NIP-04 per-conversation history: markers drive paging while on screen, a status card sits at
// the oldest end, and an empty channel bootstraps one page so there is something to scroll from.
val history = remember(accountViewModel) { accountViewModel.dataSources().concordChannelHistory.history }
val loadingHistory by history.loadingMore.collectAsStateWithLifecycle()
val historyStatus by history.status.collectAsStateWithLifecycle()
val limits =
remember(historyStatus) {
buildList {
if (!historyStatus.exhausted) {
historyStatus.relayProgress.forEach { (relay, p) ->
add(RelayReachCursor("cord:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "Concord") { history.advance(relay) })
}
}
}
}
ConcordBackfillHistoryToWindow(feedViewModel.feedState, history)
val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel")
newMessageModel.init(accountViewModel)
newMessageModel.load(communityId, channelId)
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(channel.toBestDisplayName(), maxLines = 1)
channel.communityName?.let {
Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
},
)
},
) { padding ->
Column(Modifier.fillMaxHeight().padding(padding)) {
Column(Modifier.fillMaxHeight().weight(1f, true)) {
RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = concordChannelLastReadRoute(communityId, channelId),
onWantsToReply = { newMessageModel.reply(it) },
onWantsToEditDraft = {},
// A status card at the oldest end: shows what it's reaching for while it pages and
// crossfades to "All caught up" when every relay runs dry.
olderBoundary = {
DmHistoryLoadingCard(
"Concord",
"Concord",
loadingHistory,
historyStatus.exhausted,
historyStatus.relayCount,
historyStatus.stalledCount,
historyStatus.reachedBack,
historyStatus.relayProgress,
::formatHistoryReachDate,
)
},
// Each relay's window-limit marker at its reached cursor (pure UI). Hidden when exhausted.
markersInGap =
if (limits.isEmpty()) {
null
} else {
{ newer, older -> RelayReachMarkers(limits, newer, older) {} }
},
// Pulls each relay's next page while its marker is on screen, off viewport visibility.
sentinels =
if (limits.isEmpty()) {
null
} else {
{ items, listState ->
RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt }
}
},
)
}
ConcordTypingIndicator(communityId, channelId, accountViewModel)
if (channel.canPost()) {
Spacer(modifier = DoubleVertSpacer)
ConcordMessageComposer(
newMessageModel = newMessageModel,
accountViewModel = accountViewModel,
nav = nav,
onMessageSent = { feedViewModel.feedState.sendToTop() },
)
}
}
}
}
/** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */
private const val CONCORD_HISTORY_TARGET = 50
/**
* On open, eagerly backfill this channel's older history until the feed holds at least
* [CONCORD_HISTORY_TARGET] messages (or the relays are exhausted) mirroring Armada's multi-page
* `backfillStore`. The live subscription only carries each plane's relay-capped recent tail, shared
* across one merged REQ per relay for every channel; so without this, a channel that has plenty of
* history opens showing just its last few messages until the user scrolls. Once the target is reached,
* paging is purely demand-driven by the markers' visibility. A short startup delay skips the transient
* empty feed that navigation flashes through. Supersedes the old empty-only bootstrap.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun ConcordBackfillHistoryToWindow(
feedContentState: FeedContentState,
history: ConcordChannelHistorySubAssembler,
) {
LaunchedEffect(feedContentState, history) {
delay(1200L)
// Reactive count of currently-loaded messages (0 while empty/loading).
val loadedCount =
feedContentState.feedContent.flatMapLatest { state ->
when (state) {
is FeedState.Loaded -> state.feed.map { it.list.size }
else -> flowOf(0)
}
}
// Pull another page whenever we're below target, no page is in flight, and relays aren't done.
// Each landed page grows the count (or flips exhausted), so this re-fires page-by-page and then
// latches off. The !loading gate prevents overlapping REQs / a tight loop.
combine(loadedCount, history.loadingMore, history.status) { count, loading, status ->
count < CONCORD_HISTORY_TARGET && !loading && !status.exhausted
}.distinctUntilChanged()
.filter { it }
.collect { history.advanceAll() }
}
}
/** Republish a typing heartbeat at most this often (seconds) while composing. */
private const val TYPING_HEARTBEAT_SECS = 4L
/**
* A slim "X is typing…" line above the composer, driven by the session's ephemeral
* typing heartbeats (kind 23311). A ~2s ticker re-applies the freshness window so a
* typist who stops silently fades out even without a new ingest.
*/
@Composable
private fun ConcordTypingIndicator(
communityId: String,
channelId: String,
accountViewModel: AccountViewModel,
) {
val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return
val typingMap by session.typing.collectAsStateWithLifecycle()
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
// Only tick while this channel actually has heartbeats, and stop once they've all aged out of
// the freshness window — an idle channel must not wake a 2s recomposition loop forever. A new
// heartbeat re-keys this effect (the map value changes) and restarts the fade.
LaunchedEffect(session, channelId, typingMap[channelId]) {
val perChannel = typingMap[channelId]
if (perChannel.isNullOrEmpty()) return@LaunchedEffect
while (true) {
nowSecs = TimeUtils.now()
if (perChannel.values.none { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS }) break
delay(2000L)
}
}
val typers =
remember(typingMap, channelId, nowSecs) {
(typingMap[channelId] ?: emptyMap())
.filterValues { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS }
.keys
.sorted()
}
if (typers.isEmpty()) return
val label =
when (typers.size) {
1 -> stringRes(R.string.concord_typing_one, rememberTypistName(typers[0], accountViewModel))
2 ->
stringRes(
R.string.concord_typing_two,
rememberTypistName(typers[0], accountViewModel),
rememberTypistName(typers[1], accountViewModel),
)
else -> stringRes(R.string.concord_typing_many)
}
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
}
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
@Composable
private fun rememberTypistName(
hex: String,
accountViewModel: AccountViewModel,
): String {
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
val info by observeUserInfo(user, accountViewModel)
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
}
private fun reachState(p: RelayPagingProgress): RelayReachState =
when {
p.done -> RelayReachState.DONE
p.stalled -> RelayReachState.STALLED
else -> RelayReachState.REACHING
}
private fun relayShortName(relay: NormalizedRelayUrl): String =
relay.url
.removePrefix("wss://")
.removePrefix("ws://")
.removeSuffix("/")
@Composable
private fun ConcordMessageComposer(
newMessageModel: ConcordNewMessageViewModel,
accountViewModel: AccountViewModel,
nav: INav,
onMessageSent: suspend () -> Unit,
) {
val scope = rememberCoroutineScope()
val canPost by remember { derivedStateOf { newMessageModel.canPost() } }
val context = LocalContext.current
// Throttle typing heartbeats to at most one every few seconds while the field is non-empty.
val lastTypingSecs = remember(newMessageModel.channelId) { longArrayOf(0L) }
DisposableEffect(newMessageModel.channelId) {
onDispose { newMessageModel.userSuggestions?.reset() }
}
// Encrypted image attachments: a picked image opens this dialog, which encrypts + uploads via the
// shared NIP-17 pipeline and sends an Armada-shaped image message on the channel plane.
newMessageModel.uploadState?.let { uploadState ->
uploadState.multiOrchestrator?.let {
ConcordFileUploadDialog(
newMessageModel = newMessageModel,
state = uploadState,
accountViewModel = accountViewModel,
nav = nav,
onUpload = { onMessageSent() },
onCancel = uploadState::reset,
)
}
}
newMessageModel.replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() }
ReplyModeToggle(
mode = newMessageModel.replyMode.value,
onToggle = { newMessageModel.toggleReplyMode() },
)
}
Column(modifier = EditFieldModifier) {
newMessageModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
newMessageModel::autocompleteWithUser,
accountViewModel,
SuggestionListDefaultHeightChat,
)
}
newMessageModel.emojiSuggestions?.let {
ShowEmojiSuggestionList(
it,
newMessageModel::autocompleteWithEmoji,
newMessageModel::autocompleteWithEmoji,
SuggestionListDefaultHeightChat,
)
}
ThinPaddingTextField(
state = newMessageModel.message,
onTextChanged = {
newMessageModel.onMessageChanged()
val community = newMessageModel.communityId
val channel = newMessageModel.channelId
val now = TimeUtils.now()
if (community != null && channel != null &&
newMessageModel.message.text.isNotEmpty() &&
now - lastTypingSecs[0] >= TYPING_HEARTBEAT_SECS
) {
lastTypingSecs[0] = now
accountViewModel.sendConcordTyping(community, channel)
}
},
onContentReceived = { uri, mimeType ->
newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
},
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
Text(
text = stringRes(com.vitorpamplona.amethyst.R.string.reply_here),
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) {
SelectFromGallery(
isUploading = false,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier,
onImageChosen = newMessageModel::pickedMedia,
)
}
},
trailingIcon = {
ThinSendButton(
isActive = canPost,
modifier = EditFieldTrailingIconModifier,
) {
scope.launch(Dispatchers.IO) {
try {
newMessageModel.sendPost()
onMessageSent()
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
}
/**
* The picked-image confirmation dialog for a Concord channel. Reuses the shared NIP-17 upload
* pipeline: it always encrypts (no encryption toggle is shown, and [SuccessfulUploads.toConcordImeta]
* fails closed if a cipher is somehow absent), uploads the ciphertext, then sends one Armada-shaped
* image message ([Account.sendConcordChannelImageMessage]) carrying every attachment's `imeta`.
*/
@Composable
private fun ConcordFileUploadDialog(
newMessageModel: ConcordNewMessageViewModel,
state: ChatFileUploadState,
accountViewModel: AccountViewModel,
nav: INav,
onUpload: suspend () -> Unit,
onCancel: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
ChatFileUploadDialog(
state = state,
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_send_image_title)) },
upload = {
scope.launch(Dispatchers.IO) {
val community = newMessageModel.communityId
val channel = newMessageModel.channelId
if (community == null || channel == null) return@launch
ChatFileUploader(accountViewModel.account).justUploadNIP17(
viewState = state,
onError = { title, message ->
scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
},
onEncryptedUploadError = { title, message ->
scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
},
context = context,
onceUploaded = { uploads ->
val imetas = uploads.mapNotNull { it.toConcordImeta() }
if (imetas.isNotEmpty()) {
accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas)
}
onUpload()
},
)
accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer)
accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata)
}
},
onCancel = onCancel,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* Turns an encrypted upload into the Armada-shaped `imeta` (via [ChannelChat.encryptedImageImeta]).
* Returns null when the upload carried no cipher so a non-encrypted blob is never sent as a Concord
* image (fails closed, protecting the community's end-to-end guarantee).
*/
private fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
val cipher = cipher ?: return null
return ChannelChat.encryptedImageImeta(
url = result.url,
mimeType = result.mimeTypeBeforeEncryption,
dim = result.fileHeader.dim?.toString(),
blurhash = result.fileHeader.blurHash?.blurhash,
cipher = cipher,
originalHash = result.hashBeforeEncryption,
)
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File
import java.util.concurrent.ConcurrentHashMap
// Decrypt-once memo per plaintext hash → the on-disk file:// model. Object URLs never change for a
// given hash (content-addressed), so this is safe to keep for the process lifetime and bounded by the
// number of distinct community images a session touches.
private val resolvedByHash = ConcurrentHashMap<String, String>()
/**
* Resolve a CORD-02 §6 community [pointer] to a model string for [RobohashFallbackAsyncImage]:
*
* - **null / blank** null (caller falls back to the robohash).
* - **plain URL** (a url-only pointer, e.g. Amethyst's own metadata form) the URL, loaded directly.
* - **encrypted** (`key`/`nonce`/`hash` present) fetch the ciphertext, AES-256-GCM-decrypt + verify
* the plaintext SHA-256 (all in [ImagePointer.decryptOrNull]), cache the plaintext to disk, and
* return its `file://` path. Returns null while loading or on any fetch/decrypt/integrity failure,
* so a swapped or unreachable blob simply shows the robohash instead of garbage.
*/
@Composable
fun rememberConcordImageModel(
pointer: ImagePointer?,
accountViewModel: AccountViewModel,
): String? {
if (pointer == null) return null
// A url-only pointer isn't encrypted media — hand the URL straight to Coil.
if (!pointer.isResolvable()) return pointer.url.ifBlank { null }
val context = LocalContext.current
val model by produceState<String?>(resolvedByHash[pointer.hash], pointer, accountViewModel) {
if (value != null) return@produceState
value =
withContext(Dispatchers.IO) {
runCatching {
resolvedByHash[pointer.hash]?.let { return@runCatching it }
val cacheFile = File(context.cacheDir, "concord-img-${pointer.hash}")
if (!cacheFile.exists()) {
val client = accountViewModel.httpClientBuilder.okHttpClientForImage(pointer.url)
val ciphertext =
client.newCall(Request.Builder().url(pointer.url).build()).execute().use { resp ->
if (!resp.isSuccessful) return@runCatching null
resp.body?.bytes()
} ?: return@runCatching null
val plaintext = pointer.decryptOrNull(ciphertext) ?: return@runCatching null
cacheFile.writeBytes(plaintext)
}
val uri = "file://${cacheFile.absolutePath}"
resolvedByHash[pointer.hash] = uri
uri
}.onFailure { Log.w("ConcordImage", "Failed to resolve community image ${pointer.url}", it) }
.getOrNull()
}
}
return model
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* A tappable chip naming the Concord community a message belongs to. Deliberately **muted** the same
* faint wash the note-header markers use because the community's logo is now the row avatar, so the
* name only needs to read as tappable metadata, not compete with it. (The NIP-29 relay-host chip stays
* highlighted; a relay group has no avatar of its own.) Shared by the Messages row and the Notifications
* feed so a Concord message reads the same wherever it surfaces; the name is hard-capped so a long title
* can't crowd the row.
*/
@Composable
fun ConcordCommunityPill(
communityName: String,
onClick: () -> Unit,
maxChars: Int = 20,
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f),
contentColor = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.size(11.dp),
)
Text(
text = if (communityName.length > maxChars) communityName.take(maxChars).trimEnd() + "" else communityName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* Create a new Concord Channel (encrypted community) from Amethyst. Mints the
* genesis (metadata + #general), publishes it to the chosen relays (or the
* account's outbox by default), joins it, and opens the new community.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordCreateScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val name = remember { mutableStateOf("") }
val about = remember { mutableStateOf("") }
val icon = remember { mutableStateOf<ImagePointer?>(null) }
val relays = remember { mutableListOf<NormalizedRelayUrl>().toMutableStateList() }
var working by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ConcordMetadataFields(
name = name,
about = about,
icon = icon,
robotSeed = "concord-new",
accountViewModel = accountViewModel,
)
ConcordSectionHeader(
title = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays),
description = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays_desc),
)
ConcordRelayListEditor(
relays = relays,
onRemove = { relays.remove(it) },
onAdd = { if (it !in relays) relays.add(it) },
accountViewModel = accountViewModel,
nav = nav,
)
Button(
onClick = {
if (name.value.isBlank() || working) return@Button
working = true
scope.launch {
val communityId =
try {
accountViewModel.account.createConcordCommunity(
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
relays = relays.map { it.url },
icon = icon.value,
)
} finally {
// Always re-enable — a thrown create would otherwise strand the button.
working = false
}
if (communityId != null) nav.newStack(Route.ConcordServer(communityId))
}
},
enabled = name.value.isNotBlank() && !working,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_action))
}
}
}
}
/** A section header (title + one-line description) matching the NIP-29 metadata form. */
@Composable
fun ConcordSectionHeader(
title: String,
description: String? = null,
) {
Column(Modifier.fillMaxWidth().padding(top = 4.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* Edit a Concord community's metadata (name / description / icon). Reuses the shared
* [ConcordMetadataFields] hero + fields, prefilled from the folded Control Plane, and
* saves a new metadata edition via [com.vitorpamplona.amethyst.model.Account.editConcordMetadata]
* honored on fold only when this account holds MANAGE_METADATA (or is the owner).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordEditScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
// Self-sufficient: mount the plane subscription so a deep link folds the community, and
// re-resolve the session on each revision so it resolves once the session exists.
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) }
val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle()
val name = remember { mutableStateOf("") }
val about = remember { mutableStateOf("") }
val icon = remember { mutableStateOf<ImagePointer?>(null) }
val banner = remember { mutableStateOf<ImagePointer?>(null) }
val relays = remember { mutableStateListOf<NormalizedRelayUrl>() }
var prefilled by remember { mutableStateOf(false) }
var working by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Seed the fields once, the first time the folded metadata is available. Relays come from the
// folded metadata when present, else from this account's list entry (the bootstrap set).
LaunchedEffect(state?.metadata) {
val md = state?.metadata
if (!prefilled && md != null) {
name.value = md.name
about.value = md.description.orEmpty()
icon.value = md.icon
banner.value = md.banner
val seededRelays = (md.relays.takeIf { it.isNotEmpty() } ?: session?.entry?.relays.orEmpty())
relays.clear()
relays.addAll(seededRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) })
prefilled = true
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.concord_edit_title), maxLines = 1) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
)
},
) { padding ->
if (session == null) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ConcordMetadataFields(
name = name,
about = about,
icon = icon,
robotSeed = communityId,
accountViewModel = accountViewModel,
banner = banner,
)
ConcordSectionHeader(
title = stringRes(R.string.concord_create_relays),
description = stringRes(R.string.concord_edit_relays_desc),
)
ConcordRelayListEditor(
relays = relays,
onRemove = { relays.remove(it) },
onAdd = { if (it !in relays) relays.add(it) },
accountViewModel = accountViewModel,
nav = nav,
)
Button(
onClick = {
if (name.value.isBlank() || working) return@Button
working = true
scope.launch {
val ok =
try {
account.editConcordMetadata(
communityId = communityId,
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
icon = icon.value,
banner = banner.value,
relays = relays.map { it.url },
)
} finally {
// Always re-enable — a thrown save would otherwise strand the button.
working = false
}
if (ok) nav.popBack()
}
},
enabled = name.value.isNotBlank() && !working,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(stringRes(R.string.concord_edit_save))
}
}
}
}
@@ -0,0 +1,550 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
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.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import kotlinx.coroutines.flow.combine
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The Concord Channels hub a single-screen browser of every community the account
* joined (kind-13302) and, expanded inline, that community's channels. Each row is a
* community you can expand to reveal its `#`/🔒/🎙 channels without leaving the screen.
* Tapping a channel opens its chat; the community header opens the full server view.
*
* Concord has no public directory communities are E2E-encrypted and invite-gated
* so there's no browse feed: you arrive by creating one, redeeming an invite, or (for
* a key already used on another Concord client) the on-open import from the stock
* relays below. The live plane subscription is mounted here so channels fold in while
* you browse.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordHomeScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
val account = accountViewModel.account
val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle()
// Re-read folded metadata (name / icon / channels) whenever a Control Plane folds.
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
// Concord clients (Armada/Vector) publish the kind-13302 joined list to the Concord
// stock relays, not the user's outbox, so a community joined there never surfaces at
// login. Pull it from those relays when the hub opens — scoped here so we only reach
// the stock relays for users who actually use Concord.
LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() }
// Per-community expansion, cycled on tap: absent = CLOSED → UNREAD (peek only the channels with
// new messages) → OPEN (all channels) → CLOSED. Multi-open, so several can be expanded at once.
// rememberSaveable so the chevron states survive opening a channel and coming back to the hub.
var expandStates by rememberSaveable(stateSaver = ExpandStatesSaver) { mutableStateOf(emptyMap<String, ChannelExpand>()) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.concord_home_title)) },
navigationIcon = {
// Back arrow only when this is a pushed screen (from the drawer / a deep link);
// as a bottom-nav root there is nothing to pop and the bar takes its place.
if (nav.canPop()) {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
}
},
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// so the same screen works both as a pushed destination and a bottom-nav tab.
AppBottomBar(Route.Concords, nav, accountViewModel) { route ->
if (route != Route.Concords) nav.navBottomBar(route)
}
},
floatingActionButton = {
FabBottomBarPadded(nav) {
FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }, shape = CircleShape) {
SymbolIcon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.concord_create_title),
modifier = Modifier.size(24.dp),
)
}
}
},
) { padding ->
if (communities.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringRes(R.string.concord_home_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
return@Scaffold
}
// Busiest communities first: sort by the most-recent message across their channels so the
// one you'd actually open floats to the top (recomputed as messages fold in on `revision`).
val sorted =
remember(communities, revision) {
communities.sortedByDescending { entry ->
val keys =
account.concordSessions
.sessionFor(entry.id)
?.state
?.value
?.channels
?.keys
.orEmpty()
communityActivity(entry.id, keys)
}
}
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
sorted.forEach { entry ->
val state =
account.concordSessions
.sessionFor(entry.id)
?.state
?.value
.takeIf { revision >= 0 }
val mode = expandStates[entry.id] ?: ChannelExpand.CLOSED
val channelKeys = state?.channels?.keys.orEmpty()
item(key = entry.id) {
CommunityHeader(
communityId = entry.id,
name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) },
iconPointer = state?.metadata?.icon,
channelKeys = channelKeys,
revision = revision,
mode = mode,
accountViewModel = accountViewModel,
onSetMode = { next -> expandStates = if (next == ChannelExpand.CLOSED) expandStates - entry.id else expandStates + (entry.id to next) },
onOpen = { nav.nav(Route.ConcordServer(entry.id)) },
)
}
if (mode != ChannelExpand.CLOSED && state != null) {
// A banner hero (CORD-02 §6) belongs to the full view, not the compact unread peek.
if (mode == ChannelExpand.OPEN) {
state.metadata?.banner?.let { banner ->
item(key = "banner-${entry.id}") { CommunityBanner(banner, accountViewModel) }
}
}
// Channels, most-recently-active first, each with its last message + unread state.
// In UNREAD mode a row hides itself unless it has new messages (peek).
val channels =
state.channels.entries.sortedByDescending {
LocalCache.getConcordChannelIfExists(ConcordChannelId(entry.id, it.key))?.lastNote?.createdAt() ?: 0L
}
items(channels, key = { "${entry.id}/${it.key}" }) { ch ->
val def = ch.value.definition
ConcordChannelRow(
communityId = entry.id,
channelKey = ch.key,
channelName = def?.name ?: ch.key,
icon =
when {
def?.voice == true -> MaterialSymbols.Mic
def?.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
},
hideIfRead = mode == ChannelExpand.UNREAD,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.Concord(entry.id, ch.key)) },
)
}
if (mode == ChannelExpand.UNREAD) {
item(key = "showall-${entry.id}") {
ShowAllChannelsRow(onClick = { expandStates = expandStates + (entry.id to ChannelExpand.OPEN) })
}
}
}
item(key = "div-${entry.id}") {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
/** Most-recent message time across a community's [channelKeys] (0 if none), for activity sorting. */
private fun communityActivity(
communityId: String,
channelKeys: Set<String>,
): Long =
channelKeys.maxOfOrNull { key ->
LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, key))?.lastNote?.createdAt() ?: 0L
} ?: 0L
/**
* The number of a community's channels with a message newer than this account last read there
* combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on
* [revision] so a freshly-folded message flips the badge.
*/
@Composable
private fun communityUnreadCount(
account: Account,
communityId: String,
channelKeys: Set<String>,
): Int {
if (channelKeys.isEmpty()) return 0
// Keyed only on the channel set (not the global revision): each per-channel flow reacts to both
// its last-read marker AND the channel's own notes flow, so a folded message flips the badge
// without tearing down and restarting every flow on every unrelated fold (which reset the badge
// to 0 and made it flicker).
val flow =
remember(communityId, channelKeys) {
combine(
channelKeys.map { key ->
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key))
combine(
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)),
channel.flow().notes.stateFlow,
) { lastRead, state ->
val last = state.channel.lastNote?.createdAt() ?: 0L
if (last > lastRead) 1 else 0
}
},
) { flags -> flags.sum() }
}
return flow.collectAsStateWithLifecycle(0).value
}
@Composable
private fun CommunityHeader(
communityId: String,
name: String,
iconPointer: ImagePointer?,
channelKeys: Set<String>,
revision: Int,
mode: ChannelExpand,
accountViewModel: AccountViewModel,
onSetMode: (ChannelExpand) -> Unit,
onOpen: () -> Unit,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val iconModel = rememberConcordImageModel(iconPointer, accountViewModel)
val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys)
// Tap cycles CLOSED → UNREAD → OPEN → CLOSED, skipping the UNREAD peek when nothing is unread
// (so a quiet community never lands on an empty middle state).
val next =
when (mode) {
ChannelExpand.CLOSED -> if (unread > 0) ChannelExpand.UNREAD else ChannelExpand.OPEN
ChannelExpand.UNREAD -> ChannelExpand.OPEN
ChannelExpand.OPEN -> ChannelExpand.CLOSED
}
Row(
modifier = Modifier.fillMaxWidth().clickable { onSetMode(next) }.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = communityId,
model = iconModel,
contentDescription = name,
modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onOpen),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (unread > 0) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val memberCount =
remember(revision) {
accountViewModel.account.concordSessions
.sessionFor(communityId)
?.memberCount() ?: 0
}
val parts = mutableListOf<String>()
if (channelKeys.isNotEmpty()) parts += pluralStringResource(R.plurals.concord_channel_count, channelKeys.size, channelKeys.size)
if (memberCount > 0) parts += pluralStringResource(R.plurals.concord_member_count, memberCount, memberCount)
val subtitle = parts.joinToString(" · ")
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (unread > 0) UnreadBadge(unread)
SymbolIcon(
// ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal").
symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** The community's decrypted CORD-02 §6 banner as a hero strip; renders nothing until it resolves. */
@Composable
private fun CommunityBanner(
banner: ImagePointer,
accountViewModel: AccountViewModel,
) {
val model = rememberConcordImageModel(banner, accountViewModel) ?: return
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
RobohashFallbackAsyncImage(
robot = "",
model = model,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(110.dp),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = false,
autoPlayGif = autoPlayGif,
)
}
/** A small pill showing the unread-channel count next to a community. */
@Composable
private fun UnreadBadge(count: Int) {
Box(
modifier =
Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 7.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
count.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
/**
* One channel row with its last message (author + snippet), relative time, and an unread marker
* bold + a dot when there's a message newer than this account last read the channel.
*/
@Composable
private fun ConcordChannelRow(
communityId: String,
channelKey: String,
channelName: String,
icon: MaterialSymbol,
hideIfRead: Boolean,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val account = accountViewModel.account
// getOrCreate (not getIfExists): a channel folded in the control plane may have no message-buffer
// note yet, and caching that null for the row's lifetime would leave it perpetually blank. The
// channel's own notes flow then makes lastNote reactive, so the preview/unread dot appears the
// moment its first message folds in — without keying on the global revision (which flickered the
// whole row on every unrelated fold).
val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) }
val channelState by channel
.flow()
.notes.stateFlow
.collectAsStateWithLifecycle()
val lastNote = channelState.channel.lastNote
val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle()
val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime
// In the UNREAD peek, a read channel simply isn't shown.
if (hideIfRead && !unread) return
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 40.dp, end = 16.dp)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SymbolIcon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(Modifier.weight(1f)) {
Text(
channelName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val note = lastNote
val event = note?.event
val author = note?.author
val preview: String? =
if (author != null && event != null) {
val authorName by observeUserName(author, accountViewModel)
"$authorName: ${event.content.take(80)}"
} else {
event?.content?.take(80)
}
preview?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
lastNote?.createdAt()?.let { ts ->
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (unread) {
Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary))
}
}
}
/** The footer in the UNREAD peek that expands a community to all of its channels. */
@Composable
private fun ShowAllChannelsRow(onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 40.dp, end = 16.dp)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SymbolIcon(
symbol = MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
stringRes(R.string.concord_show_all_channels),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
/** How much of a community's channel list the hub shows, cycled by tapping its header. */
private enum class ChannelExpand {
/** Header only. */
CLOSED,
/** Only channels with messages newer than this account last read them (the "peek"). */
UNREAD,
/** Every channel, plus the banner hero. */
OPEN,
}
/**
* Saver for the per-community expand map so the chevron states survive navigation (open a channel,
* come back). Serializes to an `ArrayList<String>` of `"communityId=MODE"` community ids are hex,
* so `=` never collides.
*/
private val ExpandStatesSaver =
Saver<Map<String, ChannelExpand>, ArrayList<String>>(
save = { map -> ArrayList(map.map { "${it.key}=${it.value.name}" }) },
restore = { list ->
list.associate {
val sep = it.lastIndexOf('=')
it.substring(0, sep) to ChannelExpand.valueOf(it.substring(sep + 1))
}
},
)
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Authors a CORD-02 §6 encrypted community image and returns the [ImagePointer] to seal in the
* community metadata the exact inverse of [rememberConcordImageModel]'s read path, and the same
* scheme Armada's `encryptImageBlob` + Blossom upload uses in `concord-v2/lib/image.ts`.
*
* Rather than hand-roll the upload, this drives the **standard** [UploadOrchestrator.uploadEncrypted]
* pipeline that DM/chat encrypted media uses, so a community icon gets the same treatment as any
* other attachment: image compression ([CompressorQuality]), EXIF/metadata stripping, and the
* account's configured Blossom server. The only Concord-specific parts are the fresh [AESGCM] cipher
* (whose key/nonce we keep to build the pointer) and mapping the orchestrator's result the
* ciphertext `url` plus the *plaintext* `hashBeforeEncryption` into the [ImagePointer] shape.
*/
class ConcordImageUploader(
private val account: Account,
) {
/**
* Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer.
*
* Runs on [Dispatchers.IO]: the compression/upload pipeline asserts it is off the main thread
* ([MediaCompressor] calls `checkNotInMainThread`), and callers launch this from a Compose
* `rememberCoroutineScope()`, which is Main-dispatched so without this switch the first step
* throws before any bytes leave the device.
*/
suspend fun uploadEncrypted(
uri: Uri,
context: Context,
): ImagePointer =
withContext(Dispatchers.IO) {
// Fresh random key + nonce per image; we hold onto them to build the pointer below since the
// orchestrator only surfaces the ciphertext URL, not the cipher it was handed.
val cipher = AESGCM()
val finalState =
UploadOrchestrator().uploadEncrypted(
uri = uri,
mimeType = context.contentResolver.getType(uri),
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.MEDIUM,
encrypt = cipher,
server = resolveBlossomServer(),
account = account,
context = context,
)
val result =
when (finalState) {
is UploadingState.Finished -> finalState.result
is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params))
}
val server =
result as? UploadOrchestrator.OrchestratorResult.ServerResult
?: throw IllegalStateException("Encrypted community image upload did not return a server URL")
ImagePointer(
url = server.url,
key = cipher.keyBytes.toHexKey(),
nonce = cipher.nonce.toHexKey(),
// hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it
// after decrypting, so it must match what was actually encrypted, not the original file.
hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"),
)
}
/** The account's first configured Blossom server, wrapped as a [ServerName], else the default. */
private fun resolveBlossomServer(): ServerName {
val configured =
account.blossomServers
.getBlossomServersList()
?.servers()
?.firstOrNull()
return if (configured != null) {
ServerName(configured, configured, ServerType.Blossom)
} else {
DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom }
}
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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
private sealed interface RedeemState {
data object Working : RedeemState
data class Done(
val communityId: String,
) : RedeemState
data object Failed : RedeemState
}
/**
* Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
* On open it fetches + unlocks the bundle, joins the community, and forwards to its
* channel list. On failure it offers a retry, so a transient relay miss doesn't
* strand the user.
*/
@Composable
fun ConcordInviteScreen(
link: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
var state by remember(link) { mutableStateOf<RedeemState>(RedeemState.Working) }
LaunchedEffect(link, state) {
if (state is RedeemState.Working) {
val communityId = accountViewModel.account.joinConcordViaInvite(link)
state = if (communityId != null) RedeemState.Done(communityId) else RedeemState.Failed
}
}
LaunchedEffect(state) {
(state as? RedeemState.Done)?.let { done ->
nav.newStack(Route.ConcordServer(done.communityId))
}
}
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is RedeemState.Working -> {
CircularProgressIndicator()
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite),
modifier = Modifier.padding(top = 16.dp),
textAlign = TextAlign.Center,
)
}
is RedeemState.Failed -> {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_failed),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Button(
onClick = { state = RedeemState.Working },
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.retry))
}
}
is RedeemState.Done -> Unit
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
/**
* The per-account last-read route key for one Concord channel the string
* [com.vitorpamplona.amethyst.model.Account.markAsRead]/`loadLastReadFlow` are keyed by.
* Shared by the write side (the open channel marks messages read as they show) and the
* read side (the hub's unread indicators) so the two can never drift apart.
*/
fun concordChannelLastReadRoute(
communityId: String,
channelId: String,
): String = "Concord/$communityId/$channelId"
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* How far back the member-roster harvest pages each channel. Concord membership includes every observed
* author (CORD-02 §5), but they only appear by decrypting their messages so a complete roster needs
* history, not just the live tail. This bounds the history pulled onto the device: members whose last
* post predates the window aren't counted. Tune here to trade completeness for data.
*/
private const val CONCORD_MEMBER_HARVEST_WINDOW_SECS = 90L * 24 * 60 * 60
/**
* A headless, run-once background sweep that fills in a Concord community's **full** member roster.
*
* The live channel subscriptions only carry the recent tail the relay serves, so [observedAuthors]
* (CORD-02 §5) sees only recent posters a fraction of the real membership. This pages every folded
* channel's history back to [CONCORD_MEMBER_HARVEST_WINDOW_SECS] in one pooled fetch. The wraps ride
* the app's normal ingest (the global `CacheClientConnector` `concordSessions.ingest`), which decrypts
* each and folds its author into `observedAuthors` so the members screen's count fills in as the sweep
* runs, with no extra plumbing here. `session.beginMemberHarvest()` gates it to once per community.
*
* Mount it from the members screen; it self-cancels with the composition. AUTH is free the channel
* planes' stream keys are already registered for these relays (same path the live sub uses).
*/
@Composable
fun ConcordMemberHarvest(
communityId: String,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val session = remember(communityId, revision) { account.concordSessions.sessionFor(communityId) }
androidx.compose.runtime.LaunchedEffect(session, revision) {
val s = session ?: return@LaunchedEffect
// Each folded channel's derived plane pubkey is a REQ author. Empty until the Control Plane folds
// its channels — a later revision re-runs this effect, so we harvest as soon as they appear.
val planePks = s.channelAddresses().toList()
if (planePks.isEmpty()) return@LaunchedEffect
if (!s.beginMemberHarvest()) return@LaunchedEffect
val relays =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId }
?.relays
?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
.orEmpty()
if (relays.isEmpty()) return@LaunchedEffect
val filter =
Filter(
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = planePks,
since = TimeUtils.now() - CONCORD_MEMBER_HARVEST_WINDOW_SECS,
)
withContext(Dispatchers.IO) {
runCatching {
// Events land via the global ingest path, so onEvent is a no-op — we only drive the paging.
account.client.fetchAllPagesFromPool(relays.associateWith { listOf(filter) }) { _, _ -> }
}
}
}
}
@@ -0,0 +1,337 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordMembership
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The members roster of one Concord community the analog of NIP-29's
* `RelayGroupMembersScreen`. Concord has no relay-signed roster (membership is key
* possession), so this shows the *privileged* roster derivable from the folded
* Control Plane: the owner, every role-holder (admins/moderators), and banned
* users. The overflow menu offers promote/demote (owner) and ban/unban, gated on
* the viewer's authority exactly as the write path enforces on fold.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordMembersScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
// Self-sufficient: mount the Concord plane subscription so the community folds even when this
// screen is opened directly (deep link), not only from the hub.
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
// Page every channel's bounded history once so the roster includes observed authors who only posted
// outside the live tail (CORD-02 §5) — the difference between a handful of recent posters and the
// real membership.
ConcordMemberHarvest(communityId, accountViewModel)
// Re-resolve the session as sessions are created/folded (revision-keyed), so a deep link that
// lands before the community's session exists still picks it up once it does.
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) }
val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle()
// The Guestbook membership (self-signed joins) plus everyone seen publishing a channel message
// (observed authors, CORD-02 §5) — most members never post a Join, so without the latter the
// roster collapses to just the owner + privileged roles.
val guestbookMembers by (session?.members ?: remember { MutableStateFlow(emptySet<HexKey>()) }).collectAsStateWithLifecycle()
val observedAuthors by (session?.observedAuthors ?: remember { MutableStateFlow(emptySet<HexKey>()) }).collectAsStateWithLifecycle()
val myPubKey = account.signer.pubKey
val roster =
remember(state, guestbookMembers, observedAuthors) {
val s = state ?: return@remember emptyList<RosterEntry>()
val authority = s.authority
val pubkeys =
(listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers + observedAuthors)
.map { it.lowercase() }
.distinct()
pubkeys
.map {
// The member's most-privileged role name (lowest position ranks highest), so the
// roster shows the real "Admin"/"Moderator"/custom label instead of a coarse badge.
val roleName =
authority
.rolesFor(it)
.minByOrNull { r -> r.position }
?.name
?.takeIf { n -> n.isNotBlank() }
RosterEntry(it, ConcordMembership.of(authority, it), roleName)
}.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
}
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringRes(R.string.concord_members_title), maxLines = 1)
state?.metadata?.name?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
)
},
) { padding ->
if (roster.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringRes(R.string.concord_members_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(roster, key = { it.pubkey }) { entry ->
ConcordMemberRow(
entry = entry,
communityId = communityId,
isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true),
viewerIsOwner = iAmOwner,
viewerCanBan = iCanBan,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
@Composable
private fun ConcordMemberRow(
entry: RosterEntry,
communityId: String,
isSelf: Boolean,
viewerIsOwner: Boolean,
viewerCanBan: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val user = remember(entry.pubkey) { accountViewModel.checkGetOrCreateUser(entry.pubkey) }
val isOwnerTarget = entry.membership == ConcordMembership.OWNER
val isBanned = entry.membership == ConcordMembership.BANNED
val isAdmin = entry.membership == ConcordMembership.ADMIN
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders,
// never against the owner or yourself. A banned user only offers "unban".
val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf
val canBan = viewerCanBan && !isOwnerTarget && !isSelf
// Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban.
val canRemove = viewerCanBan && !isOwnerTarget && !isSelf
val hasMenu = canToggleAdmin || canBan || canRemove
var confirmRemove by remember { mutableStateOf(false) }
if (confirmRemove) {
ConcordRemoveMemberDialog(
onConfirm = {
accountViewModel.removeConcordMember(communityId, entry.pubkey)
confirmRemove = false
},
onDismiss = { confirmRemove = false },
)
}
androidx.compose.foundation.layout.Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f)) {
if (user != null) {
UsernameDisplay(user, accountViewModel = accountViewModel)
} else {
Text(entry.pubkey.take(8), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
MemberBadge(entry.membership, entry.roleName)
if (hasMenu) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (canToggleAdmin) {
DropdownMenuItem(
text = { Text(stringRes(if (isAdmin) R.string.concord_members_remove_admin else R.string.concord_members_make_admin)) },
onClick = {
accountViewModel.setConcordAdmin(communityId, entry.pubkey, makeAdmin = !isAdmin)
expanded = false
},
)
}
if (canBan) {
DropdownMenuItem(
text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) },
onClick = {
accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned)
expanded = false
},
)
}
if (canRemove) {
DropdownMenuItem(
text = {
Text(
stringRes(R.string.concord_members_remove),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
confirmRemove = true
expanded = false
},
)
}
}
}
}
}
/** A small pill labelling the member's standing (owner / role name / banned; plain members render nothing). */
@Composable
private fun MemberBadge(
membership: ConcordMembership,
roleName: String?,
) {
val label =
when {
membership == ConcordMembership.BANNED -> stringRes(R.string.concord_role_banned)
membership == ConcordMembership.OWNER -> stringRes(R.string.concord_role_owner)
// Show the actual granted role ("Admin", "Moderator", or a custom role) rather than a
// one-size-fits-all badge; fall back to the generic "Admin" label if a role-holder's
// role name somehow didn't resolve.
roleName != null -> roleName
membership == ConcordMembership.ADMIN -> stringRes(R.string.concord_role_admin)
else -> return
}
val container = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer
val content = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer
Surface(shape = RoundedCornerShape(6.dp), color = container) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = content,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */
@Composable
private fun ConcordRemoveMemberDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.concord_members_remove_title)) },
text = { Text(stringRes(R.string.concord_members_remove_message)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringRes(R.string.concord_members_remove_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
private class RosterEntry(
val pubkey: HexKey,
val membership: ConcordMembership,
/** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */
val roleName: String?,
)
/** Owner first, then admins, then plain members, then banned last. */
private fun ConcordMembership.sortRank(): Int =
when (this) {
ConcordMembership.OWNER -> 0
ConcordMembership.ADMIN -> 1
ConcordMembership.MEMBER -> 2
ConcordMembership.NONE -> 3
ConcordMembership.BANNED -> 4
}
@@ -0,0 +1,369 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The shared metadata form for creating and editing a Concord community a large circular icon
* hero at the top (tap to pick an image, which is AES-256-GCM-encrypted and uploaded to Blossom as
* a CORD-02 §6 [ImagePointer], see [ConcordImageUploader]), then the name and description fields.
* Mirrors the NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features feel
* consistent. Callers own the state and add the surrounding scaffold, relays section (create only),
* and the create/save action.
*/
@Composable
fun ConcordMetadataFields(
name: MutableState<String>,
about: MutableState<String>,
icon: MutableState<ImagePointer?>,
robotSeed: String,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
banner: MutableState<ImagePointer?>? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
banner?.let { ConcordBannerHero(banner = it, accountViewModel = accountViewModel) }
ConcordIconHero(
robotSeed = robotSeed,
icon = icon,
displayName = name.value,
accountViewModel = accountViewModel,
)
OutlinedTextField(
value = name.value,
onValueChange = { name.value = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringRes(R.string.concord_create_name)) },
)
OutlinedTextField(
value = about.value,
onValueChange = { about.value = it },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 5,
label = { Text(stringRes(R.string.concord_create_about)) },
)
}
}
/**
* The circular community-icon hero: shows the current (decrypted) icon over a stable robohash
* placeholder, and on tap opens the photo picker encrypts + uploads the chosen image and updates
* [icon] to the resulting encrypted pointer. A spinner covers the hero while the upload is in flight.
*/
@Composable
private fun ConcordIconHero(
robotSeed: String,
icon: MutableState<ImagePointer?>,
displayName: String,
accountViewModel: AccountViewModel,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var uploading by remember { mutableStateOf(false) }
val iconModel = rememberConcordImageModel(icon.value, accountViewModel)
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
uploading = true
scope.launch {
try {
icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("ConcordImageUpload", "Community icon upload failed", e)
val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details)
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
} finally {
uploading = false
}
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier =
Modifier
.size(104.dp)
.clip(CircleShape)
.clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
contentAlignment = Alignment.Center,
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = iconModel,
contentDescription = displayName.ifBlank { stringRes(R.string.concord_create_title) },
modifier = Modifier.size(104.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
if (uploading) CircularProgressIndicator(modifier = Modifier.size(36.dp))
}
Text(
text = stringRes(R.string.concord_create_icon_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(top = 8.dp)
.clip(CircleShape)
.clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
/**
* A wide community-banner hero (a 3:1 header image): shows the current decrypted banner, and on tap
* opens the photo picker AES-256-GCM-encrypts + uploads the image and updates [banner] to the
* resulting CORD-02 §6 encrypted pointer. Tapping when a banner is set replaces it; a small remove
* button clears it. A spinner covers the hero while the upload is in flight.
*/
@Composable
private fun ConcordBannerHero(
banner: MutableState<ImagePointer?>,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var uploading by remember { mutableStateOf(false) }
val bannerModel = rememberConcordImageModel(banner.value, accountViewModel)
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
uploading = true
scope.launch {
try {
banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("ConcordImageUpload", "Community banner upload failed", e)
val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details)
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
} finally {
uploading = false
}
}
}
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(3f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
contentAlignment = Alignment.Center,
) {
if (bannerModel != null) {
AsyncImage(
model = bannerModel,
contentDescription = stringRes(R.string.concord_edit_banner_hint),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(3f),
)
}
if (uploading) {
CircularProgressIndicator(modifier = Modifier.size(36.dp))
} else if (bannerModel == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
SymbolIcon(
symbol = MaterialSymbols.AddPhotoAlternate,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Text(
text = stringRes(R.string.concord_edit_banner_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 6.dp),
)
}
}
if (bannerModel != null && !uploading) {
IconButton(
onClick = { banner.value = null },
modifier = Modifier.align(Alignment.TopEnd),
) {
SymbolIcon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.remove),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
/**
* The community's bootstrap-relay list editor, shared by the create and edit screens. Each relay is
* shown the same way the Relay Settings screens show them the relay's NIP-11 favicon, its
* advertised name, and its host and tapping the row opens the full relay-info page ([Route.RelayInfo]),
* so a community relay is a first-class, inspectable relay rather than a bare URL string. A trailing
* removes it; the [RelayUrlEditField] below adds one. State is owned by the caller.
*/
@Composable
fun ConcordRelayListEditor(
relays: List<NormalizedRelayUrl>,
onRemove: (NormalizedRelayUrl) -> Unit,
onAdd: (NormalizedRelayUrl) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
relays.forEach { relay ->
ConcordRelayRow(relay, { onRemove(relay) }, accountViewModel, nav)
}
RelayUrlEditField(
onNewRelay = onAdd,
modifier = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
nav = nav,
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ConcordRelayRow(
relay: NormalizedRelayUrl,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
// The NIP-11 relay-info doc (icon + display name), fetched + cached exactly like the settings rows.
val relayInfo by loadRelayInfo(relay)
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
RenderRelayIcon(
displayUrl = relayInfo.id ?: relay.displayUrl(),
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
pingInMs = 0,
iconModifier = MediumRelayIconModifier,
)
Spacer(Modifier.width(10.dp))
Column(
Modifier
.weight(1f)
.combinedClickable(
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
onLongClick = { scope.launch { clipboard.setText(relay.url) } },
),
) {
relayInfo.name?.takeIf { it.isNotBlank() }?.let { name ->
Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
IconButton(onClick = onRemove) {
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove))
}
}
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource
import com.vitorpamplona.amethyst.commons.actions.ConcordPlaneSub
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
/** One screen's request to keep the user's joined Concord Channels live. */
class ConcordChannelQueryState(
val account: Account,
)
/**
* Keeps every joined Concord community's planes live while a Concord-bearing
* screen is on top the Concord analog of `RelayGroupMyJoinedGroupsFilterAssembler`.
*
* Unlike NIP-29, a Concord plane wrap's `p` tag is ephemeral, so there is no
* `#p=me` subscription: each plane is fetched by its derived stream address
* (`authors=[planePk]`, kind 1059). Every joined community's Control Plane is
* subscribed upfront; once a Control Plane folds, [Account.concordSessions] bumps
* its revision and this assembler re-derives to also watch each channel's Chat
* Plane (see [ConcordChannelSubscription]).
*/
class ConcordChannelFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ConcordChannelQueryState>() {
val group =
listOf(
ConcordChannelSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class ConcordChannelSubAssembler(
client: INostrClient,
allKeys: () -> Set<ConcordChannelQueryState>,
) : PerUniqueIdEoseManager<ConcordChannelQueryState, Account>(client, allKeys) {
override fun updateFilter(
key: ConcordChannelQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val account = key.account
val entries = account.concordChannelList.liveCommunities.value
if (entries.isEmpty()) return null
// Control planes for every joined community, plus channel planes for the
// ones whose Control Plane has already folded. Deriving the channel planes
// is the only account-dependent step; collapsing planes into per-relay
// kind-1059 filters lives in the shared planner.
val subs = ArrayList<ConcordPlaneSub>()
subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries)
// The Guestbook (membership) + next-epoch base-rekey planes. Their stream keys derive from
// the entry alone, so they AUTH on the initial connection; and since the relay now
// re-authenticates on an `auth-required` CLOSED, naming them here no longer starves the
// control/channel REQ the way it did before that fix.
subs += ConcordSubscriptionPlanner.auxiliaryPlaneSubs(entries)
for (entry in entries) {
val state =
account.concordSessions
.sessionFor(entry.id)
?.state
?.value ?: continue
subs += ConcordSubscriptionPlanner.channelPlaneSubs(entry, state)
}
return ConcordSubscriptionPlanner.relayBasedFilters(subs, since)
}
override fun id(key: ConcordChannelQueryState) = key.account
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.StateFlow
/** One open Concord Channel whose older history the screen wants paged in. */
class ConcordChannelHistoryQueryState(
val account: Account,
val communityId: String,
val channelId: String,
)
/**
* Mounts the on-demand **history** pager for whichever Concord Channel screen is open. The live
* [ConcordChannelFilterAssembler] only holds the recent tail the relay serves for each channel plane;
* this pages older messages backward by `until`+`limit` per relay, exactly like the NIP-04 per-
* conversation history ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]).
*/
class ConcordChannelHistoryFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ConcordChannelHistoryQueryState>() {
val history = ConcordChannelHistorySubAssembler(client, ::allKeys)
val group = listOf(history)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
/**
* Pages one Concord Channel's older wraps by `until`+`limit`, per relay, on demand. The per-relay
* cursors live on the channel's [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel] (so
* reopening keeps progress); this binds the single-active [BackwardRelayPager] to the open channel on
* [newSub], builds the kind-1059 channel-plane REQ per armed relay, and forwards relay callbacks into
* the pager. Decryption + landing happen on the normal ingest path (the wraps flow through
* `concordSessions.ingest`); the pager only needs each wrap's `createdAt`.
*/
class ConcordChannelHistorySubAssembler(
client: INostrClient,
allKeys: () -> Set<ConcordChannelHistoryQueryState>,
) : PerUniqueIdEoseManager<ConcordChannelHistoryQueryState, ConcordChannelId>(client, allKeys) {
// Floor at `now` (liveTailSeconds = 0), NOT the DM 7-day tail: the Concord live subscription isn't a
// strict recent-tail (it asks the plane author unbounded and the relay caps the result), so paging
// must walk the WHOLE history from the top to reach "recent but capped" messages. Overlap with the
// live tail is harmless — wraps dedup by id on ingest.
private val pager = BackwardRelayPager("concord.channel.history", liveTailSeconds = 0)
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
override fun id(key: ConcordChannelHistoryQueryState) = ConcordChannelId(key.communityId, key.channelId)
// This channel's persistent paging cursors, held on its LocalCache ConcordChannel.
private fun cursorsFor(key: ConcordChannelHistoryQueryState) = LocalCache.getOrCreateConcordChannel(id(key)).history
/** The community's bootstrap relays — a channel plane may be mirrored on all of them. */
private fun relaysFor(key: ConcordChannelHistoryQueryState): Set<NormalizedRelayUrl> =
key.account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == key.communityId }
?.relays
?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
?: emptySet()
/** The channel's derived Chat Plane pubkey — the REQ author. Null until the Control Plane folds it. */
private fun planePkFor(key: ConcordChannelHistoryQueryState): String? =
key.account.concordSessions
.sessionFor(key.communityId)
?.channelPlaneAddress(key.channelId)
override fun updateFilter(
key: ConcordChannelHistoryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val planePk = planePkFor(key) ?: return emptyList()
val relays = relaysFor(key)
// Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked
// relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it.
val armed = pager.armedRelays(relays)
if (armed.isEmpty()) return emptyList()
return armed.mapNotNull { relay ->
val until = pager.requestedUntilFor(relay) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = listOf(planePk),
until = until,
limit = pager.pageLimit,
),
)
}
}
/** Steps a single [relay] to its next, older page for the open channel. Driven by its on-screen marker. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. For a channel too short to scroll. */
fun advanceAll() {
if (pager.advanceAll()) invalidateFilters()
}
override fun newSub(key: ConcordChannelHistoryQueryState): Subscription {
// Repoint the single-active orchestrator at this channel's cursors and its community relays.
pager.bind(cursorsFor(key), key.account.scope) { relaysFor(key) }
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: ConcordChannelHistoryQueryState): SubscriptionListener {
// A just-backgrounded channel's subscription can still deliver after the orchestrator rebinds to
// another channel; gate the pager (single-active) on whether it's still bound to THIS channel's
// cursors so a late callback can't move another channel's cursors. newEose runs regardless.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEose(relay)
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Mount on the open Concord Channel screen to keep its backward-history pager bound and armed. The
* channel's Chat Plane pubkey (the REQ author) is only known once the Control Plane folds, so like
* [ConcordChannelSubscription] we re-derive the history filter whenever
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] advances.
*/
@Composable
fun ConcordChannelHistorySubscription(
communityId: String,
channelId: String,
dataSource: ConcordChannelHistoryFilterAssembler,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val state = remember(account, communityId, channelId) { ConcordChannelHistoryQueryState(account, communityId, channelId) }
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
LaunchedEffect(revision) {
// A fold can reveal this channel's plane pubkey (the REQ author) for the first time.
dataSource.invalidateFilters()
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Mount on any screen that lists the user's joined Concord Channels (the Messages
* tab, the Concord home) to keep their planes live and their folded metadata in
* the LocalCache channel index.
*
* The query state is keyed on the account (stable), so the assembler wouldn't
* re-run its filter derivation on its own when a community folds or the joined set
* changes. We watch [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision]
* bumped on every join/leave and every Control-Plane fold and on each change:
* 1. refresh the LocalCache [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel]
* rows from the freshly-folded state, so list/chat UIs see the new name,
* channels and membership, then
* 2. invalidate the assembler so a newly-revealed channel plane is subscribed
* (its Chat Plane address is only known after the Control Plane folds).
*/
@Composable
fun ConcordChannelSubscription(
dataSource: ConcordChannelFilterAssembler,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val state = remember(account) { ConcordChannelQueryState(account) }
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
LaunchedEffect(revision) {
// The channel-index refresh (community name/icon, membership, ban pruning) runs
// account-wide from Account on this same revision, so the Messages tab has chips even
// when this screen was never opened. Here we only need to re-derive the subscription
// filters, since a newly-folded channel plane must now be subscribed.
dataSource.invalidateFilters()
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
/**
* Always-on account-level preload of every joined community's Control (and folded Chat) planes,
* mounted once high in the logged-in tree ([com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage])
* the Concord analog of the always-on account/DM gift-wrap tail
* ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription]).
*
* Concord control-plane wraps are addressed to *derived stream keys*, not `#p=self`, so the always-on
* DM tail never picks them up without this, communities only fold (and thus reveal their channels,
* metadata/icon and membership) while a Concord screen happens to be open. Uses the non-lifecycle
* [KeyDataSourceSubscription] so the planes stay requested app-wide, exactly like DMs, and keeps the
* same [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] watch so a
* fresh fold subscribes its newly-revealed channel planes.
*/
@Composable
fun ConcordChannelPreload(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val dataSource = accountViewModel.dataSources().concordChannels
val state = remember(account) { ConcordChannelQueryState(account) }
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
LaunchedEffect(revision) {
dataSource.invalidateFilters()
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,194 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
/**
* Composition state for the Concord channel message field, mirroring the other
* chat composers (MarmotNewMessageViewModel, ChannelNewMessageViewModel):
* @-mention user suggestions (avatar + name + NIP-05 dropdown) and reply state.
* Sending routes through [Account.sendConcordChannelMessage], which wraps the
* message on the channel plane. The typed text already carries `nostr:npub`
* mentions inline (rewritten by [UserSuggestionState.replaceCurrentWord]).
*/
@Stable
open class ConcordNewMessageViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
var communityId: HexKey? = null
var channelId: HexKey? = null
val message = TextFieldState()
val replyTo = mutableStateOf<Note?>(null)
// How the pending reply is delivered: INLINE stays in the timeline (kind-9 quote),
// MINICHAT pulls it into a thread (kind-1111). Only meaningful while replyTo is set.
val replyMode = mutableStateOf(ReplyMode.INLINE)
var userSuggestions: UserSuggestionState? = null
var emojiSuggestions: EmojiSuggestionState? = null
// Encrypted image attachments ride through the shared NIP-17 upload pipeline; a picked image
// opens the upload dialog, which encrypts + uploads and sends an Armada-shaped image message.
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
open fun init(accountVM: AccountViewModel) {
// Idempotent: the screen calls init() on every recomposition, and it recomposes often while
// paging history. Rebuilding uploadState/suggestion state each time would wipe a picked image
// mid-upload or reset an open @/emoji suggestion list, so only (re)build when the account changes.
if (::accountViewModel.isInitialized && this.accountViewModel === accountVM) return
this.accountViewModel = accountVM
this.account = accountVM.account
this.userSuggestions?.reset()
this.userSuggestions =
UserSuggestionState(
accountVM.account,
accountVM.nip05ClientBuilder(),
// Rank people who have posted in this channel first.
priorityPubkeys = { channelAuthors() },
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
}
fun pickedMedia(media: ImmutableList<SelectedMedia>) {
uploadState?.load(media)
}
private fun channelAuthors(): Set<HexKey> {
val community = communityId ?: return emptySet()
val channel = channelId ?: return emptySet()
return LocalCache
.getConcordChannelIfExists(ConcordChannelId(community, channel))
?.notes
?.mapNotNull { _, note -> note.author?.pubkeyHex }
?.toSet()
?: emptySet()
}
open fun load(
communityId: HexKey,
channelId: HexKey,
) {
if (this.communityId != communityId || this.channelId != channelId) {
this.communityId = communityId
this.channelId = channelId
this.message.clearText()
this.replyTo.value = null
}
}
fun reply(note: Note) {
replyTo.value = note
replyMode.value = ReplyMode.INLINE
}
/** Reply to [note] directly in a minichat thread (used from the minichat screen / long-press). */
fun replyInMinichat(note: Note) {
replyTo.value = note
replyMode.value = ReplyMode.MINICHAT
}
fun toggleReplyMode() {
replyMode.value = if (replyMode.value == ReplyMode.INLINE) ReplyMode.MINICHAT else ReplyMode.INLINE
}
fun clearReply() {
replyTo.value = null
replyMode.value = ReplyMode.INLINE
}
fun editFromDraft(draftMessage: String) {
message.setTextAndPlaceCursorAtEnd(draftMessage)
}
fun canPost() = message.text.isNotBlank()
fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestions?.processCurrentWord(lastWord)
emojiSuggestions?.reset()
} else if (lastWord.startsWith(":")) {
emojiSuggestions?.processCurrentWord(lastWord)
userSuggestions?.reset()
} else {
userSuggestions?.reset()
emojiSuggestions?.reset()
}
}
}
fun autocompleteWithUser(item: User) {
userSuggestions?.let {
it.replaceCurrentWord(message, message.currentWord(), item)
it.reset()
}
}
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
emojiSuggestions?.autocompleteInto(message, item)
}
/** Sends the field's text as a channel message (or a reply). Throws on failure. */
suspend fun sendPost() {
val community = communityId ?: return
val channel = channelId ?: return
val text = message.text.toString().trim()
if (text.isEmpty()) return
val parent = replyTo.value
account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
message.clearText()
clearReply()
userSuggestions?.reset()
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
class ChannelFeedFilter(
val channel: Channel,
@@ -36,12 +37,16 @@ class ChannelFeedFilter(
override fun changesFlow() = channel.changesFlow()
// returns the last Note of each user.
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { _, it -> account.isAcceptable(it) })
// A kind-1111 comment is a *minichat* reply — it lives in the thread opened from its
// root message, not as a flat sibling in the main timeline (an inline reply is a normal
// kind-9/42 message and stays). Everything else the channel gathered is a timeline message.
private fun isTimelineMessage(note: Note): Boolean = note.event !is CommentEvent && account.isAcceptable(note)
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) })
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems
.filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) }
.filter { channel.notes.containsKey(it.idHex) && isTimelineMessage(it) }
.toSet()
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
@@ -85,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.notify
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
@@ -141,6 +143,10 @@ open class ChannelNewMessageViewModel :
val replyTo = mutableStateOf<Note?>(null)
// INLINE keeps the reply in the timeline (native reply); MINICHAT sends a kind-1111
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
val replyMode = mutableStateOf(ReplyMode.INLINE)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
// Stripping failure dialog
@@ -221,11 +227,17 @@ open class ChannelNewMessageViewModel :
open fun reply(replyNote: Note) {
replyTo.value = replyNote
replyMode.value = ReplyMode.INLINE
draftTag.newVersion()
}
fun toggleReplyMode() {
replyMode.value = if (replyMode.value == ReplyMode.INLINE) ReplyMode.MINICHAT else ReplyMode.INLINE
}
fun clearReply() {
replyTo.value = null
replyMode.value = ReplyMode.INLINE
draftTag.newVersion()
}
@@ -419,6 +431,7 @@ open class ChannelNewMessageViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
val channel = channel ?: return null
val messageText = message.text.toString()
val tagger =
NewMessageTagger(
@@ -439,6 +452,25 @@ open class ChannelNewMessageViewModel :
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
// A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the
// channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/
// hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message,
// not the raw text — so replying in a thread never silently drops any of them.
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
if (minichatParent != null) {
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
if (channel is RelayGroupChannel) hTag(channel.groupId.id)
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
geoHash?.let { geohash(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
return when {
channel is PublicChatChannel -> {
val replyingToEvent = replyTo.value?.toEventHint<ChannelMessageEvent>()
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
@@ -80,6 +81,10 @@ fun EditFieldRow(
DisplayReplyingToNote(it, accountViewModel, nav) {
channelScreenModel.clearReply()
}
ReplyModeToggle(
mode = channelScreenModel.replyMode.value,
onToggle = { channelScreenModel.toggleReplyMode() },
)
}
channelScreenModel.uploadState?.let { uploading ->
@@ -20,14 +20,20 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -48,6 +54,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
@@ -78,7 +85,10 @@ import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
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.concord.ConcordCommunityPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
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
@@ -113,9 +123,10 @@ 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 } == true
baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true
)
if (baseNote.event != null || rendersWithoutEvent) {
@@ -159,6 +170,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)
@@ -171,6 +187,12 @@ private fun ChatroomEntry(
return
}
val concordChannel = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }
if (concordChannel != null) {
ConcordRoomCompose(lastMessage, concordChannel, accountViewModel, nav)
return
}
// A NIP-29 group message whose channel gatherer didn't attach (e.g. loaded before its channel
// existed, or via a path that skips attach) has no case in the when() below and would blank out.
// Resolve the group from its `h` tag + provenance relay and render the group row anyway.
@@ -415,6 +437,63 @@ private fun RelayGroupRoomCompose(
)
}
@Composable
private fun ConcordRoomCompose(
lastMessage: Note,
baseChannel: ConcordChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? ConcordChannel ?: baseChannel
val author = lastMessage.author
val noteEvent = lastMessage.event
val lastContent =
if (author != null && noteEvent != null) {
val authorName by observeUserName(author, accountViewModel)
"$authorName: ${noteEvent.content.take(200)}"
} else {
// Event-less placeholder row for a just-joined channel with no messages yet.
channel.communityName ?: stringRes(R.string.relay_group_no_messages_yet)
}
ChannelName(
channelIdHex = channel.channelId.channelId,
channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel),
channelTitle = { modifier ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
channel.communityName?.let { communityName ->
Spacer(Modifier.width(6.dp))
// The chip names the parent community and, when tapped, opens that community's
// channel list — the "chip that opens the Concord Channel" entry point.
ConcordCommunityPill(
communityName = communityName,
onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
)
}
}
},
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) },
)
}
@Composable
private fun RelayGroupServerRoomCompose(
row: RelayGroupServerRoomNote,
@@ -453,17 +532,87 @@ private fun RelayGroupServerRoomCompose(
)
}
/** A small tappable chip naming the relay a channel is hosted on. */
@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 = rememberConcordImageModel(metadata?.icon, accountViewModel),
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 tappable chip naming the server/community a Messages row belongs to. Unlike the muted
* note-header [HeaderPill] (PoW/OTS/location markers), this one is a first-class navigation entry
* point, so it keeps the stronger `secondaryContainer` highlight.
*/
@Composable
private fun RelayNameChip(
label: String,
onClick: () -> Unit,
) {
HeaderPill(
symbol = MaterialSymbols.Dns,
text = label,
onClick = onClick,
)
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(11.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
@@ -20,6 +20,8 @@
*/
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
@@ -28,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -128,7 +131,36 @@ class ChatroomListKnownFeedFilter(
}
}
return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups).toSet())
// 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 =
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())
}
override fun updateListWith(
@@ -144,11 +176,13 @@ class ChatroomListKnownFeedFilter(
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account)
val newRelevantConcord = filterRelevantConcordMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty() &&
newRelevantPublicMessages.isEmpty() &&
newRelevantEphemeralChats.isEmpty() &&
newRelevantRelayGroups.isEmpty()
newRelevantRelayGroups.isEmpty() &&
newRelevantConcord.isEmpty()
) {
return oldList
}
@@ -219,6 +253,21 @@ class ChatroomListKnownFeedFilter(
}
}
newRelevantConcord.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
if (newNotePair.key == oldNote.concordRowKey()) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) {
myNewList = myNewList.replace(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
return sort(myNewList.toSet()).take(1000)
}
@@ -230,11 +279,13 @@ class ChatroomListKnownFeedFilter(
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account)
val newRelevantConcord = filterRelevantConcordMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty() &&
newRelevantPublicMessages.isEmpty() &&
newRelevantEphemeralChats.isEmpty() &&
newRelevantRelayGroups.isEmpty()
newRelevantRelayGroups.isEmpty() &&
newRelevantConcord.isEmpty()
) {
emptySet()
} else {
@@ -242,11 +293,57 @@ class ChatroomListKnownFeedFilter(
newRelevantPrivateMessages.values +
newRelevantPublicMessages.values +
newRelevantEphemeralChats.values +
newRelevantRelayGroups.values
newRelevantRelayGroups.values +
newRelevantConcord.values
).toSet()
}
}
/**
* 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 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> {
// 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 channel = newNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return@forEach
if (newNote.event == null || !account.isAcceptable(newNote)) return@forEach
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
}
private fun filterRelevantPublicMessages(
newItems: Set<Note>,
account: Account,
@@ -303,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"
}
}
@@ -0,0 +1,84 @@
/*
* 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.utils
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* A small tappable chip shown above a chat composer while a reply is pending: it flips the
* reply between staying inline in the timeline and being pulled aside into a thread
* ("minichat"). Inline is the default; the user opts into the thread. Shared by every chat
* composer (Concord, public chats, relay groups).
*/
@Composable
fun ReplyModeToggle(
mode: ReplyMode,
onToggle: () -> Unit,
) {
val minichat = mode == ReplyMode.MINICHAT
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.clickable(onClick = onToggle),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
) {
SymbolIcon(
symbol = if (minichat) MaterialSymbols.Forum else MaterialSymbols.Chat,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(14.dp),
)
Text(
text = stringRes(if (minichat) R.string.chat_reply_in_thread else R.string.chat_reply_in_chat),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -137,11 +138,15 @@ class NotificationFeedFilter(
setOf(
BadgeAwardEvent.KIND,
ChannelMessageEvent.KIND,
// NIP-29 group chat (kind 9). A reply to my group message is a
// kind-9 that p-tags me (see ChannelNewMessageViewModel), fetched
// at startup by filterGroupNotificationsToPubkey. Without kind 9
// here the acceptableEvent kind gate drops it before the p-tag
// check, so those replies never render on the Notifications tab.
// kind-9 chat message, shared by two features:
// • NIP-29 group chat: a reply to my group message is a kind-9 that p-tags me
// (see ChannelNewMessageViewModel), fetched at startup by
// filterGroupNotificationsToPubkey.
// • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention
// p-tags me; a minichat reply is a kind-1111 CommentEvent below.
// Either way it notifies only when it p-tags me — a plain channel message tags no one
// and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these
// replies before the p-tag check, so they'd never render on the Notifications tab.
ChatEvent.KIND,
ChatMessageEvent.KIND,
ChatMessageEncryptedFileHeaderEvent.KIND,
@@ -441,6 +446,40 @@ class NotificationFeedFilter(
// Chess events bypass the follow filter — opponents may not be followed
val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent
// Concord community messages bypass the follow filter too: a reply/reaction that p-tags me
// in a community I've joined is relevant whether or not I follow that member (fellow members
// usually aren't follows). The p-tag gate below still applies, so only genuine replies /
// reactions / mentions notify — general channel chatter that doesn't tag me never does.
//
// A ConcordChannel gatherer alone isn't enough: notes live in the global LocalCache and keep a
// gatherer reference from every account/community that ever touched them, so require the
// community to be one THIS account has currently joined (mirrors the Marmot check above) —
// otherwise a note from a prior account or a left community would leak onto Notifications.
fun Note?.inJoinedConcordCommunity() =
this?.inGatherers?.any { g ->
g is ConcordChannel && account.concordSessions.sessionFor(g.channelId.communityId) != null
} == true
val isConcordMessage = it.inJoinedConcordCommunity()
// A like/repost is NOT itself attached to the channel gatherer — only chat messages/replies are
// (see LocalCache.consumeConcordRumor) — so `inGatherers` never flags it as Concord, and its
// author (a fellow member) usually isn't a follow, so it falls through the follow filter and is
// dropped. Recognize it through its TARGET: a reaction/repost pointing at a message in a
// community I've joined is a Concord reaction, and bypasses the follow filter like a reply does.
// Relevance (does it target ME) is still enforced below by the p-tag gate — a well-formed kind-7
// p-tags the reacted author (NIP-25), which is exactly what our own ChannelChat.reaction writes.
val isConcordReaction =
(noteEvent is ReactionEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent) &&
it.replyTo?.lastOrNull().inJoinedConcordCommunity()
val isConcord = isConcordMessage || isConcordReaction
// Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs
// and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so
// Concord reactions do too (only isConcordMessage is gated).
if (isConcordMessage && !showMessages) return false
// Global keeps every event that p-tags the user; Selected (and the
// follow/list modes) also applies the per-kind relevance heuristics.
val isRawGlobal = followList() is TopFilter.Global
@@ -454,11 +493,14 @@ class NotificationFeedFilter(
// to genuine replies, so unrelated channel chatter never leaks through.
return noteEvent?.kind in NOTIFICATION_KINDS &&
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
(isChessEvent || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
(isRawGlobal || tagsAnEventByUser(it, loggedInUserHex))
// For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/
// mention targets me directly), so skip the per-kind heuristic — which for a reaction would
// otherwise need my target message already loaded to resolve replyTo.
(isRawGlobal || isConcord || tagsAnEventByUser(it, loggedInUserHex))
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
@@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.search.SearchScope
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
import com.vitorpamplona.amethyst.commons.search.SearchSource
@@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
@@ -186,6 +188,15 @@ class SearchBarViewModel(
searchTerm
.mapLatest { term ->
if (term.isBlank()) return@mapLatest null
// A Concord invite link (…/invite/<naddr>#<fragment>) embeds a kind-33301 naddr that
// Nip19Parser would otherwise extract and route to the generic event screen (which
// can't render 33301). Detect the invite first and open the redeem flow — the whole
// URL is carried so the fragment token survives.
if (ConcordActions.parseInviteLink(term) != null) {
return@mapLatest Route.ConcordInvite(term)
}
val parsed =
runCatching { Nip19Parser.uriToRoute(term)?.entity }
.onFailure { if (it is CancellationException) throw it }
@@ -211,9 +222,17 @@ class SearchBarViewModel(
}
is NAddress -> {
LocalCache.consume(parsed)
routeFor(LocalCache.getOrCreateAddressableNote(parsed.address()), account)
?: Route.EventRedirect(parsed.aTag())
// A bare kind-33301 naddr is a Concord invite bundle — not renderable as a
// generic addressable event (and unredeemable without the link's fragment
// token). Send it to the invite flow, which shows a clean "needs the full
// link" state rather than an "unable to render" event screen.
if (parsed.kind == ConcordInviteBundleEvent.KIND) {
Route.ConcordInvite(term)
} else {
LocalCache.consume(parsed)
routeFor(LocalCache.getOrCreateAddressableNote(parsed.address()), account)
?: Route.EventRedirect(parsed.aTag())
}
}
else -> {
@@ -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,
@@ -1042,6 +1042,7 @@ private fun FullBleedNoteCompose(
makeItShort = false,
canPreview = canPreview,
quotesLeft = 3,
unPackReply = ReplyRenderType.NONE,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
+78
View File
@@ -306,6 +306,84 @@
<string name="already_have_an_account">Already have a Nostr account?</string>
<string name="loading_feed">Loading feed</string>
<string name="loading_account">Loading account</string>
<string name="concord_redeeming_invite">Redeeming invite…</string>
<string name="concord_invite_failed">Could not fetch this invite. The link may be expired or its relays unreachable.</string>
<string name="concord_home_title">Concord Channels</string>
<string name="concord_home_empty">You haven\'t joined any Concord Channels yet. Create one, or open an invite link.</string>
<string name="concord_channels_empty">No channels yet.</string>
<string name="concord_show_all_channels">Show all channels</string>
<string name="concord_send_image_title">Send image</string>
<string name="concord_open_channel">Open channel</string>
<string name="concord_edit_banner_hint">Add a banner</string>
<string name="concord_channel_create">New channel</string>
<string name="concord_channel_rename">Rename channel</string>
<string name="concord_channel_rename_save">Rename</string>
<string name="concord_channel_name_label">Channel name</string>
<string name="concord_channel_delete">Delete channel</string>
<string name="concord_channel_delete_title">Delete channel?</string>
<string name="concord_channel_delete_message">Delete #%1$s? This can\'t be undone and the channel can\'t be recreated with the same id.</string>
<string name="concord_channel_delete_confirm">Delete</string>
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</string>
<string name="concord_typing_one">%1$s is typing…</string>
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
<string name="concord_typing_many">Several people are typing…</string>
<string name="concord_create_title">New Concord Channel</string>
<string name="concord_create_name">Name</string>
<string name="concord_create_about">About (optional)</string>
<string name="concord_create_relays">Relays</string>
<string name="concord_create_icon">Icon URL (optional)</string>
<plurals name="concord_channel_count">
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<plurals name="concord_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<string name="concord_create_action">Create</string>
<string name="concord_invite_action">Invite people</string>
<string name="concord_invite_title">Invite link</string>
<string name="concord_make_admin">Make admin</string>
<string name="concord_remove_admin">Remove admin</string>
<string name="concord_ban_user">Ban</string>
<string name="concord_ban_user_title">Ban from this community?</string>
<string name="concord_ban_user_body">This member will be added to the community banlist. Their messages will be hidden and their future posts dropped by every member. You can unban them later.</string>
<string name="concord_create_icon_hint">Set a community icon</string>
<string name="concord_create_relays_desc">Relays that store this community\'s encrypted messages. Leave empty to use your own.</string>
<string name="concord_edit_title">Edit community</string>
<string name="concord_edit_save">Save</string>
<string name="concord_members_title">Members</string>
<string name="concord_members_empty">No owner, admins, or banned members to show yet.</string>
<string name="concord_members_make_admin">Make admin</string>
<string name="concord_members_remove_admin">Remove admin</string>
<string name="concord_members_ban">Ban</string>
<string name="concord_members_unban">Unban</string>
<string name="concord_members_remove">Remove from community</string>
<string name="concord_members_remove_title">Remove member?</string>
<string name="concord_members_remove_message">This rotates the community\'s encryption key so this member can no longer read anything sent afterwards. Everyone else is re-keyed automatically. This can\'t be undone.</string>
<string name="concord_members_remove_confirm">Remove</string>
<string name="concord_role_owner">Owner</string>
<string name="concord_role_admin">Admin</string>
<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>
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
<string name="chat_reply_in_chat">In chat</string>
<string name="chat_reply_in_thread">In thread</string>
<!-- Title of the minichat (thread) screen opened from a chat message. -->
<string name="chat_minichat_title">Thread</string>
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
<plurals name="chat_minichat_reply_count">
<item quantity="one">%1$d reply</item>
<item quantity="other">%1$d replies</item>
</plurals>
<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>
@@ -230,6 +230,7 @@ class DataDir(
val stateFile = File(root, "state.json")
val aliasesFile = File(root, "aliases.json")
val cashuFile = File(root, "cashu.json")
val concordFile = File(root, "concord.json")
val marmotDir = File(root, "marmot")
val groupsDir = File(marmotDir, "groups")
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
@@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter
@@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -58,13 +61,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
@@ -92,6 +98,7 @@ import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import java.lang.management.ManagementFactory
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
@@ -247,6 +254,41 @@ class Context(
startCap = System.getenv("AMY_RELAY_SUB_CAP")?.toIntOrNull()?.coerceIn(1, 100) ?: 16,
).also { client.addConnectionListener(it) }
/**
* Concord plane stream-key AUTH (CORD-01 §4b). Concord relays gate a plane's
* kind-1059 wraps behind NIP-42 and serve them only to a connection authenticated
* AS the plane's derived *stream key* the member is neither the wrap's author
* (the stream key) nor its recipient (a throwaway ephemeral key), so an account
* AUTH is refused. `amy concord` verbs register their control + channel stream
* secrets here (scoped to the community's relays, the same scope the plane REQ
* uses) before draining, and [relayAuth] answers a challenge from one of those
* relays with one kind-22242 per stream key signed locally from the raw derived
* key, never the account, so no user identity is exposed.
*/
private val concordStreamSecrets = ConcurrentHashMap<NormalizedRelayUrl, MutableSet<HexKey>>()
private val concordStreamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
/** Registers raw 32-byte Concord stream [secrets] to answer NIP-42 challenges from [relays]. */
fun registerConcordStreamKeys(
relays: Set<NormalizedRelayUrl>,
secrets: List<ByteArray>,
) {
if (relays.isEmpty() || secrets.isEmpty()) return
val hexes = secrets.map { it.toHexKey() }
for (relay in relays) concordStreamSecrets.getOrPut(relay) { ConcurrentHashMap.newKeySet() }.addAll(hexes)
}
/** Signs one kind-22242 per Concord stream key registered for [relay] (empty if none). */
private fun signConcordStreamAuths(
relay: NormalizedRelayUrl,
template: EventTemplate<RelayAuthEvent>,
): List<RelayAuthEvent> =
concordStreamSecrets[relay].orEmpty().mapNotNull { hex ->
runCatching {
concordStreamSigners.getOrPut(hex) { NostrSignerSync(KeyPair(privKey = hex.hexToByteArray())) }.sign(template)
}.getOrNull()
}
/**
* NIP-42 responder: answers a relay's AUTH challenge by signing with the
* account key, so auth-gated relays serve our reads instead of CLOSing the
@@ -254,16 +296,20 @@ class Context(
* Only a local key auto-signs a remote bunker signer is skipped, since a
* per-relay remote round-trip during a crawl would stall it (and signing an
* auth event with any key still unlocks relays that just want *some* auth).
* Any Concord stream keys registered via [registerConcordStreamKeys] for the
* challenging relay are signed alongside the account AUTH.
*/
private val relayAuth: RelayAuthenticator =
RelayAuthenticator(
client = client,
signWithAllLoggedInUsers = { _, template ->
if (signer is NostrSignerInternal) {
runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() }
} else {
emptyList()
}
signWithAllLoggedInUsers = { relay, template, _ ->
val accountAuth =
if (signer is NostrSignerInternal) {
runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() }
} else {
emptyList()
}
accountAuth + signConcordStreamAuths(relay, template)
},
)
@@ -596,12 +642,21 @@ class Context(
* proven-dead relays from future routing instead of paying the full
* [timeoutMs] on them again. Slow-but-connected relays are NOT reported
* only hard connect failures, so a temporarily-busy relay isn't discarded.
*
* With [pendingOnAuthRequired], a relay that refuses the REQ with an
* `auth-required` CLOSED is kept pending rather than treated as terminal: the
* NIP-42 responder answers the challenge and the client re-fires this same
* subscription (`syncFilters`), so the post-auth events are collected instead of
* returning empty. If auth never satisfies it, the relay simply falls through to
* the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
* only to a connection authenticated as the derived stream key.
*/
suspend fun drain(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000,
diagnoseSlow: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
pendingOnAuthRequired: Boolean = false,
): List<Pair<NormalizedRelayUrl, Event>> {
if (filters.isEmpty()) return emptyList()
val eventChannel = Channel<Pair<NormalizedRelayUrl, Event>>(UNLIMITED)
@@ -634,6 +689,9 @@ class Context(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Keep the relay pending on an auth-required refusal: the authenticator answers the
// challenge and re-fires this subscription, so the post-auth events still arrive.
if (pendingOnAuthRequired && MachineReadablePrefix.parse(message) == MachineReadablePrefix.AUTH_REQUIRED) return
doneChannel.trySend(relay to "closed:$message")
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.commands.AdminCommand
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
import com.vitorpamplona.amethyst.cli.commands.CountCommand
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
import com.vitorpamplona.amethyst.cli.commands.DebitCommands
@@ -300,6 +301,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
System.err.println("[amy] `wot` is deprecated — use `fof` (follows-of-follows). The computed web of trust is `graperank`.")
FofCommand.dispatch(dataDir, tail)
}
"concord" -> ConcordCommands.dispatch(dataDir, tail)
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -751,6 +753,15 @@ private fun printUsage() {
|
| marmot reset [--yes] wipe all local MLS/KeyPackage state (destructive)
|
| concord create --name NAME [--about T] [--relays wss://a,wss://b]
| create an encrypted Concord Channel community
| concord list list joined Concord communities
| concord channels COMMUNITY list a community's channels
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
| concord join URL redeem an invite link and save the community
|
|Local event store (shared, under `<data-dir>/shared/`):
| Backend selected by AMY_STORE: sqlite (default; `shared/events.db`)
| or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is
@@ -0,0 +1,147 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.TimeUtils
/** `amy concord channels|send|read` — the per-channel chat verbs. */
object ConcordChannelCommands {
private val HEX64 = Regex("[0-9a-fA-F]{64}")
suspend fun channels(
dataDir: DataDir,
rest: Array<String>,
): Int {
val handle = Args(rest).positional(0, "community")
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val state = foldState(ctx, sc)
Output.emit(
mapOf(
"name" to state.metadata?.name,
"description" to state.metadata?.description,
"icon" to state.metadata?.icon?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
"banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
"channels" to
state.channels.values.map {
mapOf("id" to it.channelIdHex, "name" to it.definition.name, "voice" to it.definition.voice, "private" to it.definition.private)
},
),
)
return 0
}
}
suspend fun send(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
val text = args.positional(2, "text")
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now())
val relays = ConcordCommands.relaysFor(ctx, sc)
// A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated.
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
val acked = ctx.publish(wrap, relays).filterValues { it }.keys
Output.emit(mapOf("event_id" to wrap.id, "channel" to channelId, "published_to" to acked.map { it.url }))
return 0
}
}
suspend fun read(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
val limit = args.intFlag("limit", 50)
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
val relays = ConcordCommands.relaysFor(ctx, sc)
// The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates.
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
val msgs = ConcordActions.channelMessages(wraps, channel, channelId, sc.rootEpoch).takeLast(limit)
Output.emit(
mapOf(
"channel" to channelId,
"count" to msgs.size,
"messages" to msgs.map { mapOf("id" to it.id, "author" to it.author, "content" to it.content, "created_at" to it.createdAt) },
),
)
return 0
}
}
/** Drain the control plane and fold it into the current community state. */
private suspend fun foldState(
ctx: Context,
sc: StoredCommunity,
): ConcordCommunityState {
val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val relays = ConcordCommands.relaysFor(ctx, sc)
// The relays gate the plane's kind-1059 behind NIP-42 as the derived stream key — register
// it so the drain's AUTH challenge is answered as the control plane, not the account.
ctx.registerConcordStreamKeys(relays, listOf(controlPlane.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner)
}
/** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */
private suspend fun resolve(
ctx: Context,
sc: StoredCommunity,
ref: String,
): String? {
if (ref == "general" && sc.generalChannelId.isNotBlank()) return sc.generalChannelId
if (HEX64.matches(ref)) return ref
val state = foldState(ctx, sc)
return state.channels.values
.firstOrNull { it.definition.name.equals(ref, ignoreCase = true) }
?.channelIdHex
?: state.channels.values
.firstOrNull { it.channelIdHex.startsWith(ref) }
?.channelIdHex
}
}
@@ -0,0 +1,194 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* `amy concord ` create, join, and drive Concord Channels (encrypted,
* serverless communities). Thin assembly over [ConcordActions] (commons) and
* [Context]; secrets persist in `~/.amy/<account>/concord.json`.
*/
object ConcordCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"concord",
tail,
"concord <create|list|channels|send|read|invite|join|roles|role|grant|ban|unban>",
mapOf(
"create" to { rest -> create(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) },
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
"invite" to { rest -> invite(dataDir, rest) },
"join" to { rest -> join(dataDir, rest) },
"roles" to { rest -> ConcordModCommands.roles(dataDir, rest) },
"role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) },
"grant" to { rest -> ConcordModCommands.grant(dataDir, rest) },
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
),
)
private suspend fun create(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val name = args.requireFlag("name")
val about = args.flag("about")
val relayArg = parseRelays(args.flag("relays"))
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays = relayArg.ifEmpty { ctx.outboxRelays().map { it.url } }
val community = ConcordActions.createCommunity(ctx.signer, name, TimeUtils.now(), about, relays)
val publishTo = normalize(relays).ifEmpty { ctx.outboxRelays() }
val acked = mutableSetOf<NormalizedRelayUrl>()
for (wrap in community.genesisWraps) acked += ctx.publish(wrap, publishTo).filterValues { it }.keys
ConcordStore(dataDir.concordFile).upsert(
StoredCommunity(
name = name,
communityId = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
generalChannelId = community.generalChannelIdHex,
relays = relays,
),
)
Output.emit(
mapOf(
"community_id" to community.communityIdHex,
"name" to name,
"general_channel_id" to community.generalChannelIdHex,
"published_to" to acked.map { it.url },
),
)
return 0
}
}
private fun list(
dataDir: DataDir,
@Suppress("UNUSED_PARAMETER") rest: Array<String>,
): Int {
val communities =
ConcordStore(dataDir.concordFile).load().map {
mapOf("name" to it.name, "community_id" to it.communityId, "owner" to it.owner, "relays" to it.relays)
}
Output.emit(mapOf("communities" to communities))
return 0
}
private suspend fun invite(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val base = args.flag("base", "https://vector.chat")!!
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays)
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
val acked = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)).filterValues { it }.keys
Output.emit(
mapOf(
"url" to minted.url,
"bundle_event_id" to minted.bundleEvent.id,
"link_signer" to minted.linkSignerPubKey,
"published_to" to acked.map { it.url },
),
)
return 0
}
}
private suspend fun join(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val url = args.positional(0, "url")
val parsed = ConcordActions.parseInviteLink(url) ?: return Output.error("bad_args", "not a valid invite link").let { 2 }
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays())
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
val bundle =
wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
?: return Output.error("not_found", "no valid bundle for this link").let { 1 }
ConcordStore(dataDir.concordFile).upsert(
StoredCommunity(
name = bundle.name,
communityId = bundle.communityId,
owner = bundle.owner,
ownerSalt = bundle.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
relays = bundle.relays,
),
)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
return 0
}
}
// ---- shared helpers (used by ConcordChannelCommands too) ------------------
fun parseRelays(csv: String?): List<String> = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
suspend fun relaysFor(
ctx: Context,
sc: StoredCommunity,
): Set<NormalizedRelayUrl> = normalize(sc.relays).ifEmpty { ctx.outboxRelays() }
fun notFound(handle: String): Int {
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
return 1
}
}
@@ -0,0 +1,177 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
/** `amy concord roles|role|grant|ban|unban` — Control Plane roles & moderation (CORD-04). */
object ConcordModCommands {
/** Lists the community's live roles and current banlist. */
suspend fun roles(
dataDir: DataDir,
rest: Array<String>,
): Int {
val handle = Args(rest).positional(0, "community")
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (_, editions) = load(ctx, sc)
val state = ConcordCommunityState.fold(editions, sc.owner)
Output.emit(
mapOf(
"roles" to
state.roles.map { (id, r) ->
mapOf("id" to id, "name" to r.name, "position" to r.position, "permissions" to r.permissions)
},
"banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray()).toList(),
),
)
return 0
}
}
/** Defines a new role: `role <community> <name> <position> PERM...` (perms by name, e.g. BAN KICK). */
suspend fun defineRole(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val name = args.positional(1, "name")
val position = args.positional(2, "position").toLongOrNull() ?: return Output.error("bad_args", "position must be an integer").let { 2 }
val permBits = args.positional.drop(3).mapNotNull { permByName(it) }
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (cp, editions) = load(ctx, sc)
val roleId = RandomInstance.bytes(32)
val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire())
val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now())
val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys
Output.emit(mapOf("role_id" to roleId.toHexKey(), "name" to name, "position" to position, "published_to" to acked.map { it.url }))
return 0
}
}
/** Grants a role to a member: `grant <community> <user> <roleId>`. */
suspend fun grant(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val userRef = args.positional(1, "user")
val roleId = args.positional(2, "roleId")
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val member = ctx.requireUserHex(userRef)
val (cp, editions) = load(ctx, sc)
val wrap = ConcordModeration.grant(ctx.signer, cp, sc.communityId.hexToByteArray(), member, listOf(roleId), editions, TimeUtils.now())
val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys
Output.emit(mapOf("member" to member, "roles" to listOf(roleId), "published_to" to acked.map { it.url }))
return 0
}
}
/** Bans a member: `ban <community> <user>`. */
suspend fun ban(
dataDir: DataDir,
rest: Array<String>,
): Int = banOrUnban(dataDir, rest, ban = true)
/** Unbans a member: `unban <community> <user>`. */
suspend fun unban(
dataDir: DataDir,
rest: Array<String>,
): Int = banOrUnban(dataDir, rest, ban = false)
private suspend fun banOrUnban(
dataDir: DataDir,
rest: Array<String>,
ban: Boolean,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val userRef = args.positional(1, "user")
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val member = ctx.requireUserHex(userRef)
val (cp, editions) = load(ctx, sc)
val cid = sc.communityId.hexToByteArray()
val wrap =
if (ban) {
ConcordModeration.ban(ctx.signer, cp, cid, member, editions, TimeUtils.now())
} else {
ConcordModeration.unban(ctx.signer, cp, cid, member, editions, TimeUtils.now())
}
val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys
Output.emit(mapOf("member" to member, "banned" to ban, "published_to" to acked.map { it.url }))
return 0
}
}
/** Drain the control plane and return its key + current editions to chain onto. */
private suspend fun load(
ctx: Context,
sc: StoredCommunity,
): Pair<GroupKey, List<ControlEdition>> {
val cp = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val relays = ConcordCommands.relaysFor(ctx, sc)
// Concord relays serve the plane's kind-1059 only to a connection AUTHed as the derived
// stream key — register the control key so the drain isn't refused (else the fold is empty).
ctx.registerConcordStreamKeys(relays, listOf(cp.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
return cp to ConcordActions.controlEditions(wraps, cp)
}
private fun permByName(name: String): Int? =
when (name.uppercase()) {
"MANAGE_ROLES" -> ConcordPermissions.MANAGE_ROLES
"MANAGE_CHANNELS" -> ConcordPermissions.MANAGE_CHANNELS
"MANAGE_METADATA" -> ConcordPermissions.MANAGE_METADATA
"KICK" -> ConcordPermissions.KICK
"BAN" -> ConcordPermissions.BAN
"MANAGE_MESSAGES" -> ConcordPermissions.MANAGE_MESSAGES
"CREATE_INVITE" -> ConcordPermissions.CREATE_INVITE
else -> null
}
}
@@ -0,0 +1,73 @@
/*
* 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.cli.stores
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.SecureFileIO
import java.io.File
/**
* A joined/created Concord community persisted locally so later `send`/`read`/
* `channels` runs can re-derive its planes. Holds the community's secrets, so the
* file is written 0600 via [SecureFileIO].
*/
data class StoredCommunity(
val name: String = "",
val communityId: String = "",
val owner: String = "",
val ownerSalt: String = "",
val root: String = "",
val rootEpoch: Long = 0,
val generalChannelId: String = "",
val relays: List<String> = emptyList(),
)
/**
* File-backed list of the account's Concord communities at `~/.amy/<account>/
* concord.json`. Reloaded per run (no in-process cache), matching Amy's
* stateless-per-invocation model.
*/
class ConcordStore(
private val file: File,
) {
fun load(): List<StoredCommunity> =
if (file.exists()) {
runCatching { Output.mapper.readValue<List<StoredCommunity>>(file.readText()) }.getOrDefault(emptyList())
} else {
emptyList()
}
fun save(list: List<StoredCommunity>) = SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(list))
/** Insert or replace by community id, keyed on the self-certifying id. */
fun upsert(community: StoredCommunity) {
val next = load().filterNot { it.communityId == community.communityId } + community
save(next)
}
/** Resolve a user-supplied handle: exact name, exact id, or a unique id/name prefix. */
fun find(handle: String): StoredCommunity? {
val all = load()
return all.firstOrNull { it.name == handle || it.communityId == handle }
?: all.singleOrNull { it.communityId.startsWith(handle) || it.name.startsWith(handle) }
}
}
@@ -0,0 +1,398 @@
/*
* 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.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.RefoundingBuild
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** One decrypted, verified Concord channel message projected for display. */
data class ConcordChatMessage(
val id: HexKey,
val author: HexKey,
val content: String,
val createdAt: Long,
val channelId: HexKey,
val epoch: Long,
)
/**
* Concord community verbs pure builders, plane-key derivation, relay-filter
* assembly, and event folding usable from amy CLI, the Android app, and any other
* non-UI consumer.
*
* Like [DmActions], this object never touches the network: create/send builders
* return events to publish, the read side takes already-fetched wraps and folds
* them. The caller (amy `Context`, an Android ViewModel) owns publish/drain and
* persistence of the community's secrets.
*/
object ConcordActions {
// ---- plane key derivation -------------------------------------------------
fun controlPlane(
communityRoot: ByteArray,
communityId: ByteArray,
rootEpoch: Long,
): GroupKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch)
fun publicChannel(
communityRoot: ByteArray,
channelId: ByteArray,
rootEpoch: Long,
): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch)
/** The Guestbook Plane address for a community at [rootEpoch] — where join/leave motions ride. */
fun guestbookPlane(
communityRoot: ByteArray,
communityId: ByteArray,
rootEpoch: Long,
): GroupKey = ConcordKeyDerivation.guestbookPlaneKey(communityRoot, communityId, rootEpoch)
/**
* The base-rotation rekey address a member watches to receive the *next* epoch's
* Refounding (CORD-06 §2): `base-rekey-pseudonym(current_root, community_id,
* rootEpoch + 1)`. Precomputed from the root the member already holds.
*/
fun nextBaseRekeyPlane(
communityRoot: ByteArray,
communityId: ByteArray,
rootEpoch: Long,
): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1)
// ---- relay filters (what to REQ) -----------------------------------------
/** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */
fun planeFilter(planePubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = listOf(planePubKeyHex))
/** Wraps across several plane addresses on one relay: kind-1059 authored by any of them. */
fun planeFilterFor(planePubKeysHex: List<HexKey>): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = planePubKeysHex)
/** The public invite bundle for a link signer. */
fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex))
/** Pending direct invites addressed to the given member (indexed by k=3313). */
fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString())))
// ---- community lifecycle --------------------------------------------------
/** Creates a community and its genesis editions (see [ConcordCommunityFactory]). */
suspend fun createCommunity(
ownerSigner: NostrSigner,
name: String,
createdAt: Long,
description: String? = null,
relays: List<String> = emptyList(),
icon: ImagePointer? = null,
): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon)
/** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */
fun controlEditions(
wraps: List<Event>,
controlPlane: GroupKey,
): List<ControlEdition> =
wraps.mapNotNull { wrap ->
ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) }
}
/** Opens the control-plane [wraps] and folds them into the live community state. */
fun foldCommunity(
wraps: List<Event>,
controlPlane: GroupKey,
ownerPubKey: HexKey,
): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey)
// ---- channel chat ---------------------------------------------------------
/** Builds an encrypted-seal channel message wrap to publish on the [channel] plane. */
suspend fun buildChannelMessage(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
text: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/**
* Builds an encrypted-seal channel message wrap carrying one or more encrypted image [imetas]
* (Armada `encryptAttachments` shape) to publish on the [channel] plane.
*/
suspend fun buildChannelImageMessage(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
text: String,
imetas: List<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */
suspend fun buildChannelInlineReply(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
parent: Event,
text: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */
suspend fun buildChannelReply(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
parent: Event,
text: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */
suspend fun buildChannelReaction(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
target: Event,
reaction: String,
createdAt: Long,
extraTags: Array<Array<String>> = emptyArray(),
): Event {
val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, extraTags)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/**
* Builds an **ephemeral** typing heartbeat wrap (kind-23311 rumor, kind-21059 wrap)
* on the [channel] plane. Relays broadcast but never store it; publish every few
* seconds while the user is composing.
*/
suspend fun buildChannelTyping(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
createdAt: Long,
): Event {
val rumor = ChannelChat.typing(authorSigner.pubKey, channelId, epoch, createdAt)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true, ephemeral = true, createdAt = createdAt)
}
/**
* Opens the channel [wraps], keeps the kind-9 messages correctly bound to
* [channelId]/[epoch], and returns them oldest-first (createdAt, then id).
*/
fun channelMessages(
wraps: List<Event>,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
): List<ConcordChatMessage> =
wraps
.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor }
.filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) }
.map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) }
.sortedWith(compareBy({ it.createdAt }, { it.id }))
/**
* Opens the channel [wraps] and returns every validated inner rumor bound to
* [channelId]/[epoch] messages (kind 9), replies (1111), reactions (7),
* deletes (5), edits, etc. as typed [Event]s. The caller lands these in a
* store keyed by rumor id so the normal reaction/reply/delete/OTS machinery
* wires up automatically. Deduping is left to that store (rumor ids are stable
* content hashes), so this may return duplicates across mirrored wraps.
*/
fun channelRumors(
wraps: List<Event>,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
): List<Event> =
wraps
.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor }
.filter { ChannelChat.isBoundTo(it, channelId, epoch) }
// ---- invites --------------------------------------------------------------
/** Builds a [CommunityInvite] from a freshly created (or joined) community's public info. */
fun inviteFor(
communityIdHex: HexKey,
ownerPubKey: HexKey,
ownerSaltHex: HexKey,
communityRootHex: HexKey,
rootEpoch: Long,
name: String,
relays: List<String>,
): CommunityInvite =
CommunityInvite(
communityId = communityIdHex,
owner = ownerPubKey,
ownerSalt = ownerSaltHex,
communityRoot = communityRootHex,
rootEpoch = rootEpoch,
relays = relays,
name = name,
)
/** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */
fun mintInviteLink(
base: String,
invite: CommunityInvite,
createdAt: Long,
relays: List<String>? = null,
): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays)
/** Parses a shareable invite URL into its pointer + private fragment. */
fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url)
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
fun openBundle(
bundleEvent: Event,
token: ByteArray,
): CommunityInvite? = ConcordInviteBundle.parse(bundleEvent, token)?.takeIf { ConcordInviteBundle.validate(it) }
/** Derives the control plane described by a redeemed [invite] so the joiner can read it. */
fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch)
// ---- guestbook (CORD-02 §5) ----------------------------------------------
/**
* Builds a self-signed Guestbook JOIN (kind 3306) wrap on the community's
* Guestbook Plane. Membership is off-consensus best-effort presence, but it is
* the member-visible roster a Refounding rotates keys to (CORD-06), so a client
* announces one on create/join to be re-keyed on future removals.
*/
suspend fun buildGuestbookJoin(
memberSigner: NostrSigner,
guestbook: GroupKey,
createdAt: Long,
inviteCreator: HexKey? = null,
inviteLabel: String? = null,
): Event {
val rumor = Guestbook.join(memberSigner.pubKey, createdAt, inviteCreator = inviteCreator, inviteLabel = inviteLabel)
return ConcordStreamEnvelope.wrap(rumor, guestbook, memberSigner, encrypted = true, createdAt = createdAt)
}
/** Opens the guestbook [wraps] into their live membership set (joins minus later leaves). */
fun guestbookMembers(
wraps: List<Event>,
guestbook: GroupKey,
): Set<HexKey> {
val latest = HashMap<HexKey, GuestbookEntry>()
for (wrap in wraps) {
val rumor = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor ?: continue
val entry = Guestbook.parse(rumor) ?: continue
val prev = latest[entry.member.lowercase()]
if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry
}
return latest.values.filter { it.action == GuestbookAction.JOIN }.mapTo(HashSet()) { it.member.lowercase() }
}
// ---- refounding / rekey (CORD-06) ----------------------------------------
/**
* Builds a whole-community Refounding (CORD-06 §3): the compacted Control Plane
* re-sealed under [newRoot] plus the base-rotation rekey blobs delivering
* [newRoot] to [recipientsXOnly]. Pure the caller sources the recipient set
* and owns publish + persistence.
*/
suspend fun buildRefounding(
rotatorSigner: NostrSigner,
communityId: HexKey,
priorRoot: ByteArray,
newRoot: ByteArray,
rootEpoch: Long,
priorControlWraps: List<Event>,
priorControlKey: GroupKey,
recipientsXOnly: List<HexKey>,
createdAt: Long,
): RefoundingBuild =
ConcordRefounding.build(
rotatorSigner = rotatorSigner,
communityId = communityId.hexToByteArray(),
priorRoot = priorRoot,
newRoot = newRoot,
rootEpoch = rootEpoch,
priorControlWraps = priorControlWraps,
priorControlKey = priorControlKey,
recipientsXOnly = recipientsXOnly,
createdAt = createdAt,
)
/**
* Receives an inbound base rotation for the member behind [recipientSigner]:
* finds the delivered new root across the buffered kind-3303 [wraps], verifying
* scope, epoch and continuity against the [priorRoot] the member holds. Returns
* the new root + rotator (for the caller to authorize) or null if not re-keyed.
*/
suspend fun openBaseRekey(
wraps: List<Event>,
baseRekey: GroupKey,
recipientSigner: NostrSigner,
priorRoot: ByteArray,
rootEpoch: Long,
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch)
}
@@ -0,0 +1,205 @@
/*
* 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.commons.actions
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
/**
* Builds the Control Plane editions (CORD-04) that drive roles and moderation:
* defining a role, granting roles to a member, and banning/unbanning members.
*
* Each is a kind-3308 edition, plaintext-sealed (so the author signature survives
* re-encryption across epochs) and wrapped on the community's Control Plane. The
* caller passes the community's **current** editions so this can chain the next
* version onto the entity's head (`version = head.version + 1`, `prevHash =
* head.hash`) and union the banlist. Authority is enforced at *fold* time by the
* `AuthorityResolver`, not here an edition whose author doesn't outrank its
* target (or trace to the owner via [citation]) is simply dropped by every client.
*
* The owner needs no [citation]; a delegated moderator must cite the grant they
* act under so the fold can verify the chain terminates at the owner.
*/
object ConcordModeration {
/** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */
private fun versioning(
current: List<ControlEdition>,
kind: ControlEntityKind,
entityId: ByteArray,
): Pair<Long, ByteArray?> {
val head = current.firstOrNull { it.entityKind == kind && it.entityId.contentEquals(entityId) }
return if (head != null) (head.version + 1) to head.hash else 0L to null
}
private suspend fun wrap(
actor: NostrSigner,
controlPlane: GroupKey,
kind: ControlEntityKind,
entityId: ByteArray,
version: Long,
prevHash: ByteArray?,
content: String,
createdAt: Long,
citation: AuthorityCitation?,
): Event {
val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, citation)
return ConcordStreamEnvelope.wrap(rumor, controlPlane, actor, encrypted = false, createdAt = createdAt)
}
/**
* Defines (or updates) a role. [roleId] is the role's stable 32-byte entity id
* generate one for a new role and reuse it to edit or [RoleEntity.deleted] it.
*/
suspend fun defineRole(
actor: NostrSigner,
controlPlane: GroupKey,
roleId: ByteArray,
role: RoleEntity,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event {
val (version, prev) = versioning(current, ControlEntityKind.ROLE, roleId)
val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role)
return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation)
}
/**
* Defines (or updates) a channel (CORD-03/04, `vsk=2`). [channelId] is the channel's stable
* 32-byte entity id generate one for a new channel and reuse it to rename, flip its
* private/voice flags, or [ChannelEntity.deleted] it (terminal; the id is never reused).
* Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner) tracing to the owner
* via [citation].
*/
suspend fun defineChannel(
actor: NostrSigner,
controlPlane: GroupKey,
channelId: ByteArray,
channel: ChannelEntity,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event {
val (version, prev) = versioning(current, ControlEntityKind.CHANNEL, channelId)
val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel)
return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation)
}
/**
* Replaces the community metadata (name / icon / description / relays). The
* metadata entity id is the community id itself (as in genesis), so this chains
* the next version onto the metadata head. Honored at fold only when [actor]
* holds MANAGE_METADATA (or is the owner) tracing to the owner via [citation].
*/
suspend fun editMetadata(
actor: NostrSigner,
controlPlane: GroupKey,
communityId: ByteArray,
metadata: MetadataEntity,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event {
val (version, prev) = versioning(current, ControlEntityKind.METADATA, communityId)
val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata)
return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation)
}
/** Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. */
suspend fun grant(
actor: NostrSigner,
controlPlane: GroupKey,
communityId: ByteArray,
member: HexKey,
roleIds: List<String>,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event {
val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray())
val (version, prev) = versioning(current, ControlEntityKind.GRANT, entityId)
val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds))
return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation)
}
/** Adds [member] to the banlist (union with the current head). */
suspend fun ban(
actor: NostrSigner,
controlPlane: GroupKey,
communityId: ByteArray,
member: HexKey,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) + member.lowercase(), current, createdAt, citation)
/** Removes [member] from the banlist. */
suspend fun unban(
actor: NostrSigner,
controlPlane: GroupKey,
communityId: ByteArray,
member: HexKey,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) - member.lowercase(), current, createdAt, citation)
/** The current banlist union across the head editions (lowercase hex). */
fun currentBanned(
current: List<ControlEdition>,
communityId: ByteArray,
): Set<HexKey> {
val entityId = ConcordKeyDerivation.banlistCoordinate(communityId)
val head = current.firstOrNull { it.entityKind == ControlEntityKind.BANLIST && it.entityId.contentEquals(entityId) }
return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet()
}
private suspend fun setBanlist(
actor: NostrSigner,
controlPlane: GroupKey,
communityId: ByteArray,
banned: Set<HexKey>,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation?,
): Event {
val entityId = ConcordKeyDerivation.banlistCoordinate(communityId)
val (version, prev) = versioning(current, ControlEntityKind.BANLIST, entityId)
val content = ConcordJson.instance.encodeToString(ListSerializer(String.serializer()), banned.sorted())
return wrap(actor, controlPlane, ControlEntityKind.BANLIST, entityId, version, prev, content, createdAt, citation)
}
}
@@ -0,0 +1,149 @@
/*
* 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.commons.actions
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* One plane to subscribe to: its stream address ([pubKeyHex]) and the relays it
* may live on. [channelId] is null for a community's Control Plane and set for a
* channel's Chat Plane.
*/
data class ConcordPlaneSub(
val channelId: ConcordChannelId?,
val pubKeyHex: String,
val relays: Set<NormalizedRelayUrl>,
)
/**
* Turns the account's joined-communities list into the per-plane relay
* subscriptions that keep Concord Channels live the Concord analog of NIP-29's
* `RelayGroupMyJoinedGroupsFilterAssembler`.
*
* Because a Concord wrap's `p` tag is ephemeral, there is no single `#p=me`
* subscription; instead each plane is fetched by its derived stream address
* (`authors=[planePk]`). The "warm all joined planes" policy is encoded here:
* every community's Control Plane is subscribed upfront ([controlPlaneSubs]), and
* once its Control Plane folds, every channel's Chat Plane is subscribed
* ([channelPlaneSubs]).
*/
object ConcordSubscriptionPlanner {
/** Control-plane subscriptions for every joined community (known from the entry alone). */
fun controlPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
entries.map { e ->
val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch)
ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = normalize(e.relays))
}
/**
* The off-channel planes every joined community subscribes to upfront (known
* from the entry alone): the Guestbook Plane (membership motions) and the
* next-epoch base-rekey address (so an inbound Refounding is received live,
* CORD-06). Both are kind-1059 wraps authored by their derived stream address.
*/
fun auxiliaryPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
entries.flatMap { e ->
val root = e.root.hexToByteArray()
val communityId = e.id.hexToByteArray()
val relays = normalize(e.relays)
val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch)
val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch)
listOf(
ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays),
ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays),
)
}
/** Chat-plane subscriptions for every live channel in a folded community [state]. */
fun channelPlaneSubs(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
): List<ConcordPlaneSub> {
val root = entry.root.hexToByteArray()
val relays = normalize(entry.relays)
return state.channels.keys.map { channelIdHex ->
val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, channelIdHex),
pubKeyHex = ch.publicKeyHex,
relays = relays,
)
}
}
/**
* Collapses [subs] into a `relay -> [filter]` map ready for a drain/subscribe.
* All plane wraps are kind-1059 authored by the plane address, so each relay
* gets one `{kinds:[1059], authors:[all plane pks on it]}` filter.
*/
fun filtersByRelay(subs: List<ConcordPlaneSub>): Map<NormalizedRelayUrl, List<Filter>> {
val authorsByRelay = HashMap<NormalizedRelayUrl, MutableList<String>>()
for (sub in subs) {
for (relay in sub.relays) authorsByRelay.getOrPut(relay) { ArrayList() }.add(sub.pubKeyHex)
}
return authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors)) }
}
/**
* Collapses [subs] into one [RelayBasedFilter] per host relay for a live
* subscription: each relay gets a single `{kinds:[1059], authors:[all plane
* pks on it], since}` filter, with [since] applied per relay from the EOSE
* map. Returns null when no plane resolves to a relay (nothing to subscribe).
*
* This is the assembler-facing shape (what a `PerUniqueIdEoseManager` returns);
* [filtersByRelay] is the one-shot drain shape (no `since`).
*/
fun relayBasedFilters(
subs: List<ConcordPlaneSub>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val authorsByRelay = HashMap<NormalizedRelayUrl, MutableSet<String>>()
for (sub in subs) {
for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex)
}
if (authorsByRelay.isEmpty()) return null
return authorsByRelay.map { (relay, authors) ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
// Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the
// live-only typing heartbeats a relay broadcasts but never stores.
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL),
authors = authors.toList(),
since = since?.get(relay)?.time,
),
)
}
}
private fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
}
@@ -0,0 +1,158 @@
/*
* 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.commons.model.concord
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* A Concord channel one encrypted chat room inside a community as a
* [Channel] the shared chat UI can render, mirroring NIP-29's
* [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel].
*
* The key difference: a Concord channel has no single relay-signed metadata
* event. Its name/flags come from the community's **folded Control Plane**, its
* decrypted messages are fed in by a subscription that holds the channel key, and
* it is addressed by the derived plane pubkey rather than a host relay so
* [relays] is the *community's* relay set (a channel may be mirrored on several),
* not a single host. Membership derives from the owner-rooted authority resolver.
*/
@Stable
class ConcordChannel(
val channelId: ConcordChannelId,
) : Channel() {
/** Channel display name from the folded ChannelMetadata, when known. */
var channelName: String? = null
private set
var isVoice: Boolean = false
private set
var isPrivate: Boolean = false
private set
/** The parent community's display name, from its folded metadata. */
var communityName: String? = null
private set
/** The parent community's encrypted-media icon pointer, from its folded metadata (null if unset). */
var communityIcon: ImagePointer? = null
private set
/** The parent community's encrypted-media banner pointer, from its folded metadata (null if unset). */
var communityBanner: ImagePointer? = null
private set
/** The community's bootstrap relays — a channel plane may be mirrored on all of them. */
var communityRelays: Set<NormalizedRelayUrl> = emptySet()
private set
/** This account's standing in the community (from the authority resolver + banlist). */
var membership: ConcordMembership = ConcordMembership.MEMBER
private set
/**
* Per-relay backward-pagination cursors for this channel's history (CORD-03). The live
* subscription only holds the recent tail the relay serves for the channel plane; older
* messages are paged in on demand by `until`+`limit` as the user scrolls, exactly like the
* NIP-04 per-conversation history. Held here so the cursors share the channel's cache lifetime.
*/
val history = RelayLoadingCursors()
/**
* Refresh this channel's metadata from a freshly-folded community [state] plus
* the community's [relays] and this account's [myPubKey]. Cheap and idempotent
* called whenever the Control Plane re-folds.
*
* Returns true when a displayed field (channel name, community name/icon,
* membership) actually changed, so the caller can invalidate the channel's
* metadata flow ([updateChannelInfo]) and thus recompose the Messages-row
* name + community chip only on a real change, not on every fold tick.
*/
fun updateFrom(
state: ConcordCommunityState,
relays: Set<NormalizedRelayUrl>,
myPubKey: HexKey,
): Boolean {
val def = state.channels[channelId.channelId]?.definition
// Channel fields keep their prior value until the channel edition folds.
val newChannelName = def?.name ?: channelName
val newVoice = def?.voice ?: isVoice
val newPrivate = def?.private ?: isPrivate
val newCommunityName = state.metadata?.name
val newCommunityIcon = state.metadata?.icon
val newCommunityBanner = state.metadata?.banner
val newMembership = ConcordMembership.of(state.authority, myPubKey)
val changed =
channelName != newChannelName ||
isVoice != newVoice ||
isPrivate != newPrivate ||
communityName != newCommunityName ||
communityIcon != newCommunityIcon ||
communityBanner != newCommunityBanner ||
membership != newMembership
channelName = newChannelName
isVoice = newVoice
isPrivate = newPrivate
communityName = newCommunityName
communityIcon = newCommunityIcon
communityBanner = newCommunityBanner
communityRelays = relays
membership = newMembership
return changed
}
/** A Concord channel is reachable on any of its community's relays. */
override fun relays(): Set<NormalizedRelayUrl> = communityRelays
override fun toBestDisplayName(): String = channelName ?: channelId.channelId
fun canPost(): Boolean = membership.isMember()
// Synthetic note representing this channel in the Messages list before any
// message has loaded (so a just-joined channel appears immediately). Mirrors
// RelayGroupChannel.placeholderNote().
private val placeholderLock = KmpLock()
private var cachedPlaceholder: Note? = null
fun placeholderNote(): Note =
placeholderLock.withLock {
cachedPlaceholder ?: Note(placeholderIdHex(channelId)).apply {
addGatherer(this@ConcordChannel)
cachedPlaceholder = this
}
}
companion object {
fun placeholderIdHex(channelId: ConcordChannelId): HexKey = "concord-empty-${channelId.toKey()}"
}
}
@@ -0,0 +1,140 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
/** Persistence hook for the last-known kind 13302 event (offline backup). */
interface ConcordListRepository {
fun concordList(): ConcordCommunityListEvent?
fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?)
}
/**
* The account's home base for Concord Channels: the kind-13302
* [ConcordCommunityListEvent] (self-encrypted joined-communities list). This is
* the Concord analog of NIP-29's [RelayGroupListState], but the entries carry the
* community secrets (root/salt/epoch/private-channel keys), so decryption yields
* everything needed to re-derive each plane on any device.
*
* Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and
* [liveServers] (the distinct community ids the "server" rail). [follow]/
* [unfollow] read-modify-write the list; the caller publishes the returned event.
*/
class ConcordChannelListState(
val signer: NostrSigner,
val cache: ICacheProvider,
val scope: CoroutineScope,
val settings: ConcordListRepository,
) {
// Long-term reference so the GC doesn't collect the note itself.
val concordListNote = cache.getOrCreateAddressableNote(getConcordListAddress())
fun getConcordListAddress() = ConcordCommunityListEvent.createAddress(signer.pubKey)
fun getConcordListFlow(): StateFlow<NoteState> = concordListNote.flow().metadata.stateFlow
fun getConcordList(): ConcordCommunityListEvent? = concordListNote.event as? ConcordCommunityListEvent
/** Decrypts the current list (or the offline backup) into its entries. */
suspend fun entriesWithBackup(note: Note): List<ConcordCommunityListEntry> {
val event = note.event as? ConcordCommunityListEvent ?: settings.concordList()
return event?.decrypt(signer) ?: emptyList()
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveCommunities: StateFlow<List<ConcordCommunityListEntry>> =
getConcordListFlow()
.transformLatest { noteState ->
emit(entriesWithBackup(noteState.note))
}.onStart {
emit(entriesWithBackup(concordListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
/** The distinct community ids across the joined list — the "servers" rail. */
@OptIn(ExperimentalCoroutinesApi::class)
val liveServers: StateFlow<Set<String>> =
liveCommunities
.transformLatest { entries -> emit(entries.mapTo(mutableSetOf()) { it.id }) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
/** Add or replace [entry] (by community id) and return the new signed list event to publish. */
suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent {
// Seed from the offline backup as well as the live cache event: the saved list is
// consumed into the cache asynchronously in `init`, so a join that races that load
// would otherwise start from an empty `current` and wipe every prior membership.
val current = entriesWithBackup(concordListNote)
val next = current.filterNot { it.id == entry.id } + entry
return ConcordCommunityListEvent.create(signer, next)
}
/** Drop the community with [communityId] and return the new list event, or null if none existed. */
suspend fun unfollow(communityId: String): ConcordCommunityListEvent? {
val current = entriesWithBackup(concordListNote)
if (current.none { it.id == communityId }) return null
val next = current.filterNot { it.id == communityId }
return ConcordCommunityListEvent.create(signer, next)
}
init {
settings.concordList()?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved concord list")
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
cache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "ConcordList Collector Start")
getConcordListFlow().collect { noteState ->
(noteState.note.event as? ConcordCommunityListEvent)?.let {
settings.updateConcordListTo(it)
}
}
}
}
}
@@ -0,0 +1,388 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* A validated inner chat rumor emitted by a session: its parent [communityId] and
* [channelIdHex], plus the typed [rumor] (kind 9 message, 1111 reply, 7 reaction,
* 5 delete, ). The sink lands it in a store keyed by rumor id so the normal
* reaction/reply/delete/OTS/zap machinery wires up automatically.
*/
typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event) -> Unit
/**
* The result of feeding one wrap to a session's [ConcordCommunitySession.ingest]. It separates
* "was it ours" from "did it change structure", so only structure-changing wraps bump the session
* revision (and thus re-derive plane subscriptions). Landing every chat message as a revision bump
* re-REQs every plane per message and gets the client rate-limited off the relays.
*/
enum class ConcordIngestOutcome {
/** The wrap is not addressed to any plane this session knows. Keep routing it elsewhere. */
NOT_MINE,
/** Ours and applied, but nothing the subscription set / folded structure depends on changed
* a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */
NON_STRUCTURAL,
/** Ours and changed structure: a Control-Plane fold (metadata/channels/membership/authority), a
* guestbook membership change, or a buffered base-rekey. Bumps the revision. */
STRUCTURAL,
;
/** True when the wrap belonged to this session (whether or not it changed structure). */
val claimed get() = this != NOT_MINE
}
/**
* The live read-model of one joined Concord community, driven by inbound stream
* wraps fed via [ingest].
*
* It holds the community's [entry] (with secrets), derives the Control Plane
* address up front, and as control wraps arrive re-folds the Control Plane
* into [state] (metadata + channels + authority) and re-derives each channel's
* Chat Plane address so subsequent channel wraps decrypt. **It does not store
* messages itself:** each validated chat rumor is handed to [onRumor], whose
* platform-side sink lands it in the shared event store (`LocalCache`) as a real
* Note attached to the channel so previews, threading, reactions and zaps reuse
* the same machinery every other chat does. Re-emitting is safe because the sink
* dedups by rumor id. This is the stateful counterpart to the pure
* [ConcordActions]/[ConcordPlaneRegistry] helpers.
*/
class ConcordCommunitySession(
val entry: ConcordCommunityListEntry,
val myPubKey: HexKey,
private val onRumor: ConcordRumorSink = { _, _, _ -> },
) {
private val root = entry.root.hexToByteArray()
private val communityIdBytes = entry.id.hexToByteArray()
private val controlPlaneKey: GroupKey = ConcordActions.controlPlane(root, communityIdBytes, entry.rootEpoch)
/** The Guestbook Plane at this epoch — where member join/leave motions ride (CORD-02 §5). */
private val guestbookKey: GroupKey = ConcordActions.guestbookPlane(root, communityIdBytes, entry.rootEpoch)
/**
* The base-rotation rekey address for the *next* epoch (CORD-06 §2). A member
* precomputes it from the root they already hold so an inbound Refounding which
* delivers the next root here is received live rather than only on re-open.
*/
private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch)
/** The Control Plane stream address to subscribe to (known from the entry alone). */
val controlPlaneAddress: HexKey get() = controlPlaneKey.publicKeyHex
/** The Guestbook Plane stream address to subscribe to (known from the entry alone). */
val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex
/** The next-epoch base-rekey stream address to watch for an inbound Refounding. */
val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex
private val lock = KmpLock()
// Deduped inbound wraps.
private val controlWraps = LinkedHashMap<HexKey, Event>()
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
// channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold.
private var channelKeysByAddress = HashMap<HexKey, Pair<HexKey, GroupKey>>()
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
val state: StateFlow<ConcordCommunityState?> = _state
private val _members = MutableStateFlow<Set<HexKey>>(emptySet())
/** The live Guestbook membership set (self-signed joins minus later leaves). */
val members: StateFlow<Set<HexKey>> = _members
private val _observedAuthors = MutableStateFlow<Set<HexKey>>(emptySet())
/**
* Everyone whose decrypted channel message this session has seen (lowercase hex). CORD-02 §5:
* "an author seen publishing is observably present, auto-included even if their Join never
* arrived." Most members never send a Guestbook Join, so this is the bulk of the real roster.
*/
val observedAuthors: StateFlow<Set<HexKey>> = _observedAuthors
private var memberHarvestStarted = false
/**
* Returns true exactly once for the caller that should run the one-shot full-history member-roster
* harvest (page every channel's history back to a bounded window so ingest can fold the older posters
* into [observedAuthors]). Idempotent, so re-opening the members screen never re-pages.
*/
fun beginMemberHarvest(): Boolean =
lock.withLock {
if (memberHarvestStarted) {
false
} else {
memberHarvestStarted = true
true
}
}
// channelIdHex -> (other member pubkey -> createdAt secs of their latest typing heartbeat).
private val typingByChannel = HashMap<HexKey, HashMap<HexKey, Long>>()
private val _typing = MutableStateFlow<Map<HexKey, Map<HexKey, Long>>>(emptyMap())
/**
* The latest typing-heartbeat time (createdAt secs) per channel per *other* member (kind 23311,
* CORD-03). The UI applies its own freshness window and shows those still typing.
*/
val typing: StateFlow<Map<HexKey, Map<HexKey, Long>>> = _typing
/**
* The community's full membership (lowercase hex): everyone who announced on the Guestbook or
* was seen publishing a channel message ([observedAuthors]), plus the owner and every
* role-holder, minus the banned. Best-effort a member who joined without a Guestbook motion,
* holds no role, and never posted is invisible (key possession leaves no trace), so this is a
* floor, not a census.
*/
fun allMembers(): Set<HexKey> {
val s = _state.value
val roster = if (s != null) s.authority.roleHolders() + s.ownerPubKey.lowercase() else emptySet()
val banned = s?.authority?.bannedMembers().orEmpty()
return (_members.value + _observedAuthors.value + roster) - banned
}
/** The size of [allMembers] — the community's true (best-effort) member count. */
fun memberCount(): Int = allMembers().size
/** The current Chat Plane addresses to subscribe to, one per folded channel. */
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys.toSet() }
/** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key }
/** The base-rotation rekey [GroupKey] a member opens an inbound Refounding under. */
fun nextBaseRekeyKey(): GroupKey = nextBaseRekeyKey
/** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */
fun pendingBaseRekeyWraps(): List<Event> = lock.withLock { baseRekeyWraps.values.toList() }
/**
* Every stream key whose kind-1059 wraps this session reads: the Control Plane plus
* one per folded channel. These are the identities a NIP-42 relay must see the
* connection authenticate as (kind 22242) to serve the wraps a Concord wrap is
* authored by the stream key and `p`-tagged to a throwaway ephemeral key, so the
* member is neither author nor recipient and the relay refuses unless we AUTH as the
* stream key itself.
*
* The Guestbook + next-epoch base-rekey planes ([auxStreamKeys]) are intentionally
* NOT included here: mixing them into the shared control/channel AUTH set starved the
* subscription on relays that gate a REQ on stream-key AUTH (control stopped folding,
* channels went empty). They AUTH on their own isolated subscription instead.
*/
fun streamKeys(): List<GroupKey> =
lock.withLock {
listOf(controlPlaneKey) + channelKeysByAddress.values.map { it.second }
}
/** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey)
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
fun controlEditions(): List<ControlEdition> = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) }
/** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */
fun controlPlaneWraps(): List<Event> = lock.withLock { controlWraps.values.toList() }
/** The Control Plane key, for authoring moderation editions. */
fun controlPlaneKey(): GroupKey = controlPlaneKey
/** This account's standing, from the current fold. */
fun membership(): ConcordMembership {
val s = _state.value ?: return ConcordMembership.MEMBER
return ConcordMembership.of(s.authority, myPubKey)
}
/**
* Ingests a stream [wrap]. If it belongs to this community's Control Plane it
* re-folds; if it belongs to a known channel plane it re-projects that
* channel's messages. The [ConcordIngestOutcome] tells the caller both whether
* the wrap was ours and crucially whether it changed *structure* (a fold that
* moves the subscription set / metadata) versus just landing a chat message. Only
* a [ConcordIngestOutcome.STRUCTURAL] result should bump the session revision;
* bumping on every message re-derives every plane's REQ per message and rate-limits
* the relays (they close the plane subs mid-load, so channels appear empty).
*/
fun ingest(wrap: Event): ConcordIngestOutcome {
when (wrap.pubKey) {
controlPlaneAddress -> {
lock.withLock {
if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
}
refold()
return ConcordIngestOutcome.STRUCTURAL
}
guestbookAddress -> {
lock.withLock {
if (guestbookWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
}
refoldGuestbook()
return ConcordIngestOutcome.STRUCTURAL
}
nextBaseRekeyAddress -> {
// Buffer only — decrypting a base-rotation blob needs the account signer, so the
// app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. That
// drain runs off the revision tick, so a buffered rekey must bump (rare — a rekey,
// not a message).
lock.withLock { baseRekeyWraps[wrap.id] = wrap }
return ConcordIngestOutcome.STRUCTURAL
}
else -> {
val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE
val (channelIdHex, key) = channelRef
// An ephemeral wrap on a channel plane is a transient signal (typing) — fold it into
// the typing state, never into the stored message buffer or the Note sink. The typing
// UI collects the [typing] StateFlow directly, so this needs no structural revision bump.
if (wrap.kind == ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL) {
ingestTyping(wrap, channelIdHex, key)
return ConcordIngestOutcome.NON_STRUCTURAL
}
val isNew =
lock.withLock {
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null
}
// Project only the newly-arrived wrap — the buffer's earlier wraps were already
// emitted when they landed, so re-decrypting the whole history on every message
// would be O(history) per message (quadratic over a channel's lifetime). A duplicate
// re-delivery (isNew == false) is a no-op. A full-history sweep (member-roster harvest)
// relies on this staying O(1) per wrap.
if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap))
// A chat message lands in the feed via [onRumor] → LocalCache, independent of the
// revision; it changes no plane address, so it must NOT bump (see the storm note above).
return ConcordIngestOutcome.NON_STRUCTURAL
}
}
}
private fun ingestTyping(
wrap: Event,
channelIdHex: HexKey,
key: GroupKey,
) {
val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, entry.rootEpoch)) return
val who = rumor.pubKey.lowercase()
if (who == myPubKey.lowercase()) return // never show my own typing back to me
val now = TimeUtils.now()
// Update the map and publish inside the lock so a concurrent heartbeat on another
// channel can't publish an older snapshot last and drop this channel's typers.
lock.withLock {
val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() }
val prev = perChannel[who]
// Clamp a peer's heartbeat to our clock: a wildly future-dated createdAt would never
// fall out of the freshness window below and would block later real heartbeats.
val stamp = minOf(rumor.createdAt, now)
if (prev == null || stamp > prev) perChannel[who] = stamp
perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS }
if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex)
_typing.value = typingByChannel.mapValues { it.value.toMap() }
}
}
private fun refold() {
// Read the buffer, fold, re-derive channel keys, and publish state atomically under the
// lock so a concurrent control wrap can't publish a smaller fold last. Control editions
// are rare (not per-message), so serializing the fold is cheap.
val newChannels =
lock.withLock {
val wraps = controlWraps.values.toList()
val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner)
val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first }
val next = HashMap<HexKey, Pair<HexKey, GroupKey>>()
for (channelIdHex in folded.channels.keys) {
val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
next[key.publicKeyHex] = channelIdHex to key
}
channelKeysByAddress = next
_state.value = folded
folded.channels.keys.filterNot { it in prevChannels }
}
// Project only channels appearing for the first time. Existing channels' wraps were already
// emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so
// a channel's buffer never pre-dates its first fold) — re-projecting all channels on every
// control edition would be O(channels × history) of redundant decryption.
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
}
private fun refoldGuestbook() {
lock.withLock {
val wraps = guestbookWraps.values.toList()
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
}
}
/** Re-decrypts and re-projects a channel's WHOLE wrap buffer. Only for a re-fold (keys may change). */
private fun reprojectChannel(channelIdHex: HexKey) {
val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return
val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return
emitChannelRumors(channelIdHex, key, wraps)
}
/**
* Decrypts + validates [wraps] on [channelIdHex], hands each bound rumor to the sink (which dedups
* by rumor id, so re-emitting is idempotent), and folds their authors into [observedAuthors] every
* author we decrypt is observably present (CORD-02 §5), a member even without a Guestbook Join.
*/
private fun emitChannelRumors(
channelIdHex: HexKey,
key: GroupKey,
wraps: List<Event>,
) {
val authors = HashSet<HexKey>()
ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor ->
authors.add(rumor.pubKey.lowercase())
onRumor(entry.id, channelIdHex, rumor)
}
// Every author we just decrypted is observably present (CORD-02 §5), so fold them into the
// roster even if they never posted a Guestbook Join. Atomic so a concurrent add isn't lost.
if (authors.isNotEmpty()) {
_observedAuthors.update { if (it.containsAll(authors)) it else it + authors }
}
}
companion object {
/** A typing heartbeat is considered current for this many seconds after it's seen. */
const val TYPING_STALE_SECS = 8L
}
}
@@ -0,0 +1,87 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A user's standing in a Concord community, derived from the locally-folded
* Control Plane (the owner-rooted [AuthorityResolver] + banlist).
*
* Unlike NIP-29 (where the relay's signed roster is the truth), Concord
* membership is **key possession**: anyone holding the community key is at least a
* [MEMBER]. Roles layer moderation power on top ([ADMIN]/[OWNER]); the banlist
* removes standing ([BANNED]). [NONE] is only for a user we can't place at all.
*/
enum class ConcordMembership {
/** The community founder — supreme, unremovable. */
OWNER,
/** Holds at least one moderation permission (manage roles/channels, kick, ban…). */
ADMIN,
/** Holds the community key, no elevated role. */
MEMBER,
/** On the community banlist — dropped everywhere. */
BANNED,
/** Not placeable in this community. */
NONE,
;
/** True when the user is an active participant (holds the key and isn't banned). */
fun isMember(): Boolean = this == OWNER || this == ADMIN || this == MEMBER
/** True when the user may take moderation actions. */
fun canModerate(): Boolean = this == OWNER || this == ADMIN
companion object {
private val MOD_BITS =
intArrayOf(
ConcordPermissions.MANAGE_ROLES,
ConcordPermissions.MANAGE_CHANNELS,
ConcordPermissions.MANAGE_METADATA,
ConcordPermissions.KICK,
ConcordPermissions.BAN,
ConcordPermissions.MANAGE_MESSAGES,
)
/**
* Classifies [pubKey] against a folded [authority]. [holdsKey] tells us the
* user is a member of this community locally (we joined it / hold its key),
* which distinguishes a plain [MEMBER] from [NONE].
*/
fun of(
authority: AuthorityResolver,
pubKey: HexKey,
holdsKey: Boolean = true,
): ConcordMembership {
if (authority.isBanned(pubKey)) return BANNED
if (authority.isOwner(pubKey)) return OWNER
val perms = authority.effectivePermissions(pubKey)
if (MOD_BITS.any { perms.has(it) }) return ADMIN
return if (holdsKey) MEMBER else NONE
}
}
}
@@ -0,0 +1,114 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
/** What kind of plane an address belongs to. */
enum class ConcordPlaneKind {
CONTROL,
CHANNEL,
}
/** A known Concord plane: its kind, community, optional channel, and the key to open its wraps. */
class ConcordPlane(
val kind: ConcordPlaneKind,
val communityId: HexKey,
val channelId: ConcordChannelId?,
val key: GroupKey,
)
/** The routed result of opening an inbound wrap that belonged to a known plane. */
class RoutedRumor(
val plane: ConcordPlane,
val opened: OpenedStreamEvent,
)
/**
* Maps derived plane addresses (`group_key.pk`) to the keys that open them, so an
* inbound kind-1059 wrap can be recognized as Concord traffic and decrypted with
* the right per-plane key.
*
* This is the counterpart to [ConcordChannelListState] on the read path: the list
* gives the community secrets, and this registry expands them into the concrete
* plane addresses to watch. Because a Concord wrap's `p` tag is ephemeral, address
* matching (`wrap.pubkey` registered plane) is the only way to route it a
* non-member never registers the address, so they never decrypt.
*
* Control-plane addresses are known from a community entry alone; channel-plane
* addresses become known only after the Control Plane folds ([registerChannels]).
* Thread-safe so the ingest path and UI can share one registry.
*/
class ConcordPlaneRegistry {
private val lock = KmpLock()
private val planes = HashMap<HexKey, ConcordPlane>()
/** Registers every joined community's Control Plane address. Idempotent. */
fun registerControlPlanes(entries: List<ConcordCommunityListEntry>) =
lock.withLock {
for (e in entries) {
val cp = ConcordKeyDerivation.controlPlaneKey(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch)
planes[cp.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CONTROL, e.id, null, cp)
}
}
/** Registers the Chat Plane address of every channel in a folded community [state]. */
fun registerChannels(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
) = lock.withLock {
val root = entry.root.hexToByteArray()
for (channelIdHex in state.channels.keys) {
val ch =
com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch)
}
}
/** True if [pubKeyHex] is a Concord plane address this account can open. */
fun isKnownPlane(pubKeyHex: HexKey): Boolean = lock.withLock { pubKeyHex in planes }
fun planeFor(pubKeyHex: HexKey): ConcordPlane? = lock.withLock { planes[pubKeyHex] }
/**
* If [wrap] is a kind-1059 event at a registered plane address, opens it and
* returns the routed rumor; otherwise null (not Concord, or not ours to read).
*/
fun route(wrap: Event): RoutedRumor? {
val plane = planeFor(wrap.pubKey) ?: return null
val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.key) ?: return null
return RoutedRumor(plane, opened)
}
fun clear() = lock.withLock { planes.clear() }
}
@@ -0,0 +1,165 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* The account-scoped owner of the live Concord read-path. It keeps the
* [ConcordSessionRegistry] in step with the joined-communities list and turns
* each community's folds into a single observable tick the app layer watches to
* (re)derive subscription filters and refresh its channel index.
*
* Wiring, per account:
* - Construct once with the account's `liveCommunities` flow, its pubkey, and a
* long-lived [scope]. It self-starts a collector that [ConcordSessionRegistry.sync]s
* on every list change and, for each **new** session, watches its
* [ConcordCommunitySession.state] so a fold bumps [revision].
* - Feed every inbound kind-1059 wrap through [ingest]; a Concord plane wrap is
* applied (control re-fold, channel re-project) and returns true, so the
* caller can stop treating it as a NIP-59 DM.
* - Read [subscribeAddresses] to build the `authors` set for the kind-1059
* subscription; re-read it whenever [revision] advances (a fold reveals new
* channel planes to watch).
*
* Everything platform-specific (the LocalCache channel index, the actual REQ
* mounting) stays in the app layer, which reacts to [revision]; this class holds
* no Android/UI dependency so it stays unit-testable.
*/
class ConcordSessionManager(
private val communities: StateFlow<List<ConcordCommunityListEntry>>,
private val myPubKey: HexKey,
private val scope: CoroutineScope,
private val onRumor: ConcordRumorSink = { _, _, _ -> },
) {
val registry = ConcordSessionRegistry(onRumor)
private val _revision = MutableStateFlow(0)
/** Monotonic counter bumped whenever the joined set or any community's fold changes. */
val revision: StateFlow<Int> = _revision
private val lock = KmpLock()
private val stateWatchers = HashMap<HexKey, Job>() // communityId -> state collector
init {
scope.launch {
communities.collect { entries -> onCommunitiesChanged(entries) }
}
}
private fun onCommunitiesChanged(entries: List<ConcordCommunityListEntry>) {
val created = registry.sync(entries, myPubKey)
val wantedIds = entries.mapTo(HashSet()) { it.id }
lock.withLock {
// Cancel watchers for communities we've left.
val departed = stateWatchers.keys.filterNot { it in wantedIds }
for (id in departed) stateWatchers.remove(id)?.cancel()
// Watch each newly-created session so its folds bump the revision. A Refounding
// rebuilds a still-joined community's session in place (same id, new root/epoch),
// so it comes back in `created` while its old watcher is still running — cancel
// that stale collector before replacing the map entry, or every Refounding leaks
// a coroutine holding a dead session and bumping the revision forever.
for (id in created) {
val session = registry.sessionFor(id) ?: continue
stateWatchers.remove(id)?.cancel()
stateWatchers[id] =
scope.launch {
session.state.collect { bumpRevision() }
}
}
}
bumpRevision()
}
private fun bumpRevision() {
// Called from the communities collector, every per-session state watcher, and the
// ingest path — different coroutines/dispatchers — so the increment must be atomic
// or concurrent bumps are lost.
_revision.update { it + 1 }
}
/** The `authors` set (control + known channel planes) for the kind-1059 subscription. */
fun subscribeAddresses(): Set<HexKey> = registry.subscribeAddresses()
/**
* The stream secret keys that must answer a NIP-42 AUTH challenge from [relay]:
* every plane of every joined community whose relays include [relay] the Control
* Plane + folded channels ([ConcordCommunitySession.streamKeys]) plus the Guestbook
* and next-epoch base-rekey planes ([ConcordCommunitySession.auxStreamKeys]). Concord
* relays serve a plane's kind-1059 wraps only to a connection authenticated as that
* stream key, so the relay-auth layer signs a kind-22242 with each of these (locally,
* never the user's signer) without them the plane REQ is refused. Since the relay
* re-authenticates on an `auth-required` CLOSED, a key revealed only after the Control
* Plane folds (a channel) is picked up on the retry; the aux keys derive from the entry
* alone, so they authenticate on the initial connection.
*/
fun streamAuthSecretsFor(relay: NormalizedRelayUrl): List<ByteArray> {
val out = ArrayList<ByteArray>()
for (session in registry.sessions()) {
val relays = session.entry.relays.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relay in relays) {
session.streamKeys().forEach { out.add(it.secretKey) }
session.auxStreamKeys().forEach { out.add(it.secretKey) }
}
}
return out
}
/**
* Route an inbound stream wrap; true if it was a Concord plane wrap we applied. Only a wrap that
* changed community *structure* (a fold, a membership/rekey change not a plain chat message)
* bumps the revision: bumping per message re-derives every plane subscription per message and
* gets the client rate-limited off the relays (which then close the plane subs mid-load). Chat
* messages still reach the feed via the rumor sink LocalCache, independent of the revision.
*/
fun ingest(wrap: Event): Boolean {
val outcome = registry.ingest(wrap)
if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision()
return outcome.claimed
}
fun sessions() = registry.sessions()
fun sessionFor(communityId: HexKey) = registry.sessionFor(communityId)
fun destroy() {
lock.withLock {
stateWatchers.values.forEach { it.cancel() }
stateWatchers.clear()
}
registry.clear()
}
}
@@ -0,0 +1,115 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* The account-wide coordinator for every joined Concord community: it holds one
* live [ConcordCommunitySession] per community id and fans inbound stream wraps
* out to whichever session owns them. This is the read-path analog of
* [ConcordChannelListState] on the write path the list yields the joined
* [ConcordCommunityListEntry] set, this expands each into a folding read-model.
*
* The app layer drives it from two directions:
* - [sync] whenever the joined list changes (from `liveCommunities`), which
* creates sessions for new communities and drops sessions for departed ones
* while **preserving** the already-folded state of the ones that remain.
* - [ingest] for every inbound kind-1059 wrap, which routes it to the matching
* session (control plane re-fold; channel plane re-project messages).
*
* [subscribeAddresses] returns the union of every session's control- and
* channel-plane addresses exactly the `authors` set a subscription must watch
* for kind-1059 wraps. Thread-safe: the ingest path and UI share one instance.
*/
class ConcordSessionRegistry(
private val onRumor: ConcordRumorSink = { _, _, _ -> },
) {
private val lock = KmpLock()
// communityId -> live folding session. Insertion-ordered for stable iteration.
private val sessions = LinkedHashMap<HexKey, ConcordCommunitySession>()
/**
* Reconcile the held sessions with the current joined [entries]. Sessions for
* communities still present are kept as-is (their folded state survives);
* sessions for communities no longer joined are dropped; new communities get a
* fresh session. Returns the set of community ids whose sessions were created.
*/
fun sync(
entries: List<ConcordCommunityListEntry>,
myPubKey: HexKey,
): Set<HexKey> =
lock.withLock {
val wanted = entries.associateBy { it.id }
// Drop sessions for communities we've left.
sessions.keys.retainAll(wanted.keys)
// Add sessions for newly-joined communities, and rebuild a session whose access
// material changed under it — a Refounding rotates the community_root and bumps
// the epoch (CORD-06), so the persisted entry now describes a different set of
// planes; the session is a pure function of its entry, so we recreate it to
// re-derive every address and re-fold under the new root.
val created = mutableSetOf<HexKey>()
for ((id, entry) in wanted) {
val existing = sessions[id]
if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) {
sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor)
created += id
}
}
created
}
fun sessionFor(communityId: HexKey): ConcordCommunitySession? = lock.withLock { sessions[communityId] }
fun sessions(): List<ConcordCommunitySession> = lock.withLock { sessions.values.toList() }
/** The union of control- and channel-plane addresses across all sessions to subscribe to. */
fun subscribeAddresses(): Set<HexKey> =
lock.withLock {
val out = HashSet<HexKey>()
for (session in sessions.values) {
out += session.controlPlaneAddress
out += session.channelAddresses()
}
out
}
/**
* Routes an inbound stream [wrap] to whichever session recognizes it, returning that session's
* [ConcordIngestOutcome] (or [ConcordIngestOutcome.NOT_MINE] if none claim it). A wrap belongs to
* at most one plane, so the first accepting session wins.
*/
fun ingest(wrap: Event): ConcordIngestOutcome {
val snapshot = lock.withLock { sessions.values.toList() }
for (session in snapshot) {
val outcome = session.ingest(wrap)
if (outcome != ConcordIngestOutcome.NOT_MINE) return outcome
}
return ConcordIngestOutcome.NOT_MINE
}
fun clear() = lock.withLock { sessions.clear() }
}
@@ -0,0 +1,40 @@
/*
* 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.commons.model.concord
/**
* How joined Concord Channels surface in the Messages inbox (mirrors
* `RelayGroupViewMode`).
*/
enum class ConcordViewMode {
/** One inbox row per channel across all joined communities. */
INLINE,
/** One inbox row per community, collapsing its channels behind a drill-down. */
GROUPED,
;
companion object {
val DEFAULT = INLINE
fun fromName(name: String?): ConcordViewMode = entries.firstOrNull { it.name == name } ?: DEFAULT
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.util.isValidUrl
@@ -375,6 +376,12 @@ class RichTextParser {
}
if (urls.withScheme.contains(word)) {
// A Concord invite link is a plain https URL, so it would otherwise render as a bare
// link. Cheap substring gates keep the base64/bech32 parse off the hot path for
// ordinary URLs; only `…/invite/…#…` shapes are actually decoded.
if (word.contains("/invite/") && word.contains('#') && ConcordActions.parseInviteLink(word) != null) {
return ConcordInviteLinkSegment(word)
}
parseNowhereLink(word)?.let { return it }
return LinkSegment(word)
}
@@ -164,6 +164,16 @@ class RelayGroupLinkSegment(
segment: String,
) : Segment(segment)
/**
* A Concord invite link (`/invite/<naddr>#<fragment>`). Rendered as a tappable
* chip that opens the redeem flow; [segmentText] is the whole literal (including
* the URL fragment, which carries the unlock token and never hits a server).
*/
@Immutable
class ConcordInviteLinkSegment(
segment: String,
) : Segment(segment)
@Immutable
class BlossomUriSegment(
segment: String,
@@ -0,0 +1,36 @@
/*
* 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.commons.viewmodels
/**
* How a chat reply is delivered, chosen by the user at send time.
*
* - [INLINE] a normal chat message that references its parent and stays in the
* main timeline (the chat protocol's native reply: NIP-C7 kind-9 `q` quote,
* kind-42 reply, kind-9 `+h` reply, kind-14 reply). The default.
* - [MINICHAT] a kind-1111 NIP-22 comment rooted at the parent message, pulled
* out of the timeline into a "chat within a chat" (minichat) opened from the
* parent. Wire-compatible with Soapbox Armada's thread replies.
*/
enum class ReplyMode {
INLINE,
MINICHAT,
}
@@ -0,0 +1,135 @@
/*
* 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.commons.actions
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ConcordActionsTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun createFoldSendReadRoundTrip() =
runTest {
val community = ConcordActions.createCommunity(owner, "Test Server", createdAt = 1L, relays = listOf("wss://r.example"))
// Fold genesis -> live state
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
assertEquals("Test Server", state.metadata?.name)
assertTrue(state.channels.containsKey(community.generalChannelIdHex))
// Send + read a channel message
val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch)
val wrap = ConcordActions.buildChannelMessage(owner, channel, community.generalChannelIdHex, community.rootEpoch, "hello world", createdAt = 2L)
val msgs = ConcordActions.channelMessages(listOf(wrap), channel, community.generalChannelIdHex, community.rootEpoch)
assertEquals(1, msgs.size)
assertEquals("hello world", msgs[0].content)
assertEquals(owner.pubKey, msgs[0].author)
}
@Test
fun inviteMintParseAndOpen() =
runTest {
val community = ConcordActions.createCommunity(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val invite =
ConcordActions.inviteFor(
communityIdHex = community.communityIdHex,
ownerPubKey = community.ownerPubKey,
ownerSaltHex = community.ownerSalt.toHex(),
communityRootHex = community.communityRoot.toHex(),
rootEpoch = community.rootEpoch,
name = "Nostrichs",
relays = listOf("wss://r.example"),
)
val minted = ConcordActions.mintInviteLink("https://vector.chat", invite, createdAt = 1L)
val parsed = ConcordActions.parseInviteLink(minted.url)
assertNotNull(parsed)
val opened = ConcordActions.openBundle(minted.bundleEvent, parsed.fragment.token)
assertNotNull(opened)
assertEquals(community.communityIdHex, opened.communityId)
// The joiner can derive the control plane and read the genesis.
val controlPlane = ConcordActions.controlPlaneFor(opened)
val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.owner)
assertEquals("Nostrichs", state.metadata?.name)
}
@Test
fun guestbookJoinFoldsIntoMembership() =
runTest {
val community = ConcordActions.createCommunity(owner, "Test", createdAt = 1L, relays = listOf("wss://r.example"))
val alice = NostrSignerInternal(KeyPair())
val bob = NostrSignerInternal(KeyPair())
val guestbook = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch)
val joins =
listOf(
ConcordActions.buildGuestbookJoin(alice, guestbook, createdAt = 2L),
ConcordActions.buildGuestbookJoin(bob, guestbook, createdAt = 3L),
)
val members = ConcordActions.guestbookMembers(joins, guestbook)
assertEquals(setOf(alice.pubKey.lowercase(), bob.pubKey.lowercase()), members)
}
@Test
fun refoundingReKeysRetainedAndSeversRemoved() =
runTest {
val community = ConcordActions.createCommunity(owner, "Test", createdAt = 1L, relays = listOf("wss://r.example"))
val alice = NostrSignerInternal(KeyPair()) // retained
val carol = NostrSignerInternal(KeyPair()) // removed
val newRoot = ByteArray(32) { 0x33 }
val build =
ConcordActions.buildRefounding(
rotatorSigner = owner,
communityId = community.communityIdHex,
priorRoot = community.communityRoot,
newRoot = newRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = community.genesisWraps,
priorControlKey = community.controlPlane,
recipientsXOnly = listOf(owner.pubKey, alice.pubKey),
createdAt = 5L,
)
val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch)
val aliceGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, alice, community.communityRoot, community.rootEpoch)
val carolGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, carol, community.communityRoot, community.rootEpoch)
assertNotNull(aliceGot)
assertEquals(community.rootEpoch + 1, aliceGot.newEpoch)
assertTrue(carolGot == null)
// The compacted Control Plane folds identically under the new root.
val newControl = ConcordActions.controlPlane(aliceGot.newRoot, community.communityId, aliceGot.newEpoch)
val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey)
assertEquals("Test", state.metadata?.name)
assertTrue(state.channels.isNotEmpty())
}
private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
}
@@ -0,0 +1,97 @@
/*
* 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.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ConcordModerationTest {
private val owner = NostrSignerInternal(KeyPair())
private val admin = NostrSignerInternal(KeyPair())
private val troll = NostrSignerInternal(KeyPair())
@Test
fun ownerDefinesRoleGrantsItAndBans() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val cp = community.controlPlane
val communityId = community.communityId
// Accumulate the community's editions as we publish more.
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
fun add(wrap: com.vitorpamplona.quartz.nip01Core.core.Event) {
editions += ConcordActions.controlEditions(listOf(wrap), cp)
}
// Owner defines an "Admin" role (position 1) that can BAN and KICK.
val roleId = ByteArray(32) { (it + 1).toByte() }
val roleIdHex = roleId.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
val adminRole =
RoleEntity(
name = "Admin",
position = 1,
permissions = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.KICK).toWire(),
)
add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L))
// Owner grants that role to the admin user.
add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L))
// Owner bans the troll.
add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L))
val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey)
// The role exists, the admin holds BAN + the role id, and the troll is banned.
assertTrue(state.roles.containsKey(roleIdHex))
assertTrue(state.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN))
assertTrue(roleIdHex in state.authority.rolesOf(admin.pubKey))
assertTrue(state.authority.isBanned(troll.pubKey))
assertFalse(state.authority.isBanned(admin.pubKey))
// Revoking (an empty grant, as "Remove admin" does) strips the role and its permissions.
add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L))
val demoted = ConcordCommunityState.fold(editions, community.ownerPubKey)
assertFalse(demoted.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN))
assertTrue(demoted.authority.rolesOf(admin.pubKey).isEmpty())
// Unbanning the troll clears the flag (version chains onto the ban).
add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L))
val healed = ConcordCommunityState.fold(editions, community.ownerPubKey)
assertFalse(healed.authority.isBanned(troll.pubKey))
// A grant forged by the troll (who outranks nobody) is dropped by the fold.
val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L)
val forgedEditions: List<ControlEdition> = editions + ConcordActions.controlEditions(listOf(forged), cp)
val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey)
assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN))
}
}
@@ -0,0 +1,107 @@
/*
* 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.commons.actions
import com.vitorpamplona.amethyst.commons.relays.MutableTime
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordSubscriptionPlannerTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun controlAndChannelSubsMatchDerivedAddresses() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
// Control-plane sub address must equal the derived control plane pk.
val controlSubs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))
assertEquals(1, controlSubs.size)
assertEquals(community.controlPlane.publicKeyHex, controlSubs[0].pubKeyHex)
assertTrue(controlSubs[0].channelId == null)
// Channel-plane subs cover the folded #general channel.
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val channelSubs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state)
val general = channelSubs.firstOrNull { it.channelId?.channelId == community.generalChannelIdHex }
assertTrue(general != null)
assertEquals(
ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch).publicKeyHex,
general.pubKeyHex,
)
// filtersByRelay collapses to one kind-1059 author filter per relay.
val filters = ConcordSubscriptionPlanner.filtersByRelay(controlSubs + channelSubs)
assertEquals(1, filters.size) // single relay
val filter = filters.values.first().first()
assertEquals(listOf(1059), filter.kinds)
assertTrue(filter.authors!!.contains(community.controlPlane.publicKeyHex))
assertTrue(filter.authors!!.contains(general.pubKeyHex))
}
@Test
fun relayBasedFiltersCollapsePerRelayAndApplySince() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val subs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))
val relay = RelayUrlNormalizer.normalizeOrNull("wss://r.example")!!
// One filter for the single relay, carrying the derived since. Live subscriptions ask for
// both the stored wrap (1059) and the ephemeral wrap (21059) that carries typing heartbeats.
val filters = ConcordSubscriptionPlanner.relayBasedFilters(subs, mutableMapOf(relay to MutableTime(1234L)))!!
assertEquals(1, filters.size)
assertEquals(relay, filters[0].relay)
assertEquals(listOf(1059, 21059), filters[0].filter.kinds)
assertEquals(1234L, filters[0].filter.since)
assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.publicKeyHex))
// No planes resolve to a relay -> nothing to subscribe.
assertNull(ConcordSubscriptionPlanner.relayBasedFilters(emptyList(), null))
}
}
@@ -0,0 +1,109 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ConcordCommunitySessionTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun ingestsControlThenChannelWrapsIntoFlows() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val captured = mutableListOf<Triple<String, String, com.vitorpamplona.quartz.nip01Core.core.Event>>()
val session = ConcordCommunitySession(entry, owner.pubKey) { communityId, channelIdHex, rumor -> captured += Triple(communityId, channelIdHex, rumor) }
assertEquals(community.controlPlane.publicKeyHex, session.controlPlaneAddress)
// Feed the genesis control wraps → state folds, channels + membership resolve. A fold is
// STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision.
community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(it)) }
val state = session.state.value
assertEquals("Nostrichs", state?.metadata?.name)
assertTrue(state!!.channels.containsKey(community.generalChannelIdHex))
assertEquals(ConcordMembership.OWNER, session.membership())
// The #general channel plane is now a known address.
val general = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch)
assertTrue(session.channelAddresses().contains(general.publicKeyHex))
// A channel message wrap decrypts and is emitted to the sink for #general.
val msgWrap = ConcordActions.buildChannelMessage(owner, general, community.generalChannelIdHex, community.rootEpoch, "gm all", 2L)
// A chat message lands in the feed but is NON_STRUCTURAL: it must never bump the revision
// (per-message re-subscription is what rate-limited the plane REQs and emptied channels).
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(msgWrap))
val general9 = captured.filter { it.second == community.generalChannelIdHex && it.third.content == "gm all" }
assertEquals(1, general9.size)
assertEquals(community.communityIdHex, general9[0].first)
assertEquals(owner.pubKey, general9[0].third.pubKey)
val message = general9[0].third
// A reaction to that message decrypts as a kind-7 bound to the channel, e-tagging the target.
val reactionWrap = ConcordActions.buildChannelReaction(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "🤙", 3L)
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(reactionWrap))
val reaction = captured.map { it.third }.first { it.kind == 7 }
assertEquals("🤙", reaction.content)
assertEquals(message.id, reaction.tags.first { it[0] == "e" }[1])
// A reply decrypts as a kind-1111 NIP-22 thread comment: uppercase `E` at the thread
// root and lowercase `e` at the immediate parent (both the message here), still bound
// to the channel so it groups into the message's thread — the shape Armada threads.
val replyWrap = ConcordActions.buildChannelReply(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "gm back", 4L)
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(replyWrap))
val reply = captured.map { it.third }.first { it.content == "gm back" }
assertEquals(1111, reply.kind)
assertEquals(message.id, reply.tags.first { it[0] == "E" }[1])
assertEquals(message.id, reply.tags.first { it[0] == "e" }[1])
assertTrue(ChannelChat.isBoundTo(reply, community.generalChannelIdHex, community.rootEpoch))
// Each incoming wrap is projected to the sink exactly ONCE (message + reaction + reply = 3),
// never by re-decrypting the whole channel buffer per message — the O(1) ingest path that
// keeps a full-history member harvest from being O(n²).
assertEquals(3, captured.size)
// Both channel authors observed (owner posted all three) — folded into the roster.
assertEquals(setOf(owner.pubKey.lowercase()), session.observedAuthors.value)
// A stray wrap from a different community is ignored.
val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example"))
assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(outsider.genesisWraps.first()))
}
}
@@ -0,0 +1,73 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
class ConcordMembershipTest {
private val owner = "0f".repeat(32)
private val admin = "a1".repeat(32)
private val member = "b2".repeat(32)
private val banned = "c3".repeat(32)
private val stranger = "d4".repeat(32)
private val adminRole = "11".repeat(32)
private fun ed(
kind: ControlEntityKind,
eid: String,
content: String,
author: String = owner,
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
private val authority =
AuthorityResolver.resolve(
listOf(
ed(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), // KICK|BAN|MANAGE_ROLES
ed(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$admin","role_ids":["$adminRole"]}"""),
ed(ControlEntityKind.BANLIST, "44".repeat(32), """["$banned"]"""),
),
owner,
)
@Test
fun classifiesStandingFromAuthority() {
assertEquals(ConcordMembership.OWNER, ConcordMembership.of(authority, owner))
assertEquals(ConcordMembership.ADMIN, ConcordMembership.of(authority, admin))
assertEquals(ConcordMembership.MEMBER, ConcordMembership.of(authority, member))
assertEquals(ConcordMembership.BANNED, ConcordMembership.of(authority, banned))
// A user we don't hold the key for reads as NONE.
assertEquals(ConcordMembership.NONE, ConcordMembership.of(authority, stranger, holdsKey = false))
}
@Test
fun capabilityHelpers() {
assertEquals(true, ConcordMembership.OWNER.canModerate())
assertEquals(true, ConcordMembership.ADMIN.canModerate())
assertEquals(false, ConcordMembership.MEMBER.canModerate())
assertEquals(true, ConcordMembership.MEMBER.isMember())
assertEquals(false, ConcordMembership.BANNED.isMember())
}
}
@@ -0,0 +1,83 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordPlaneRegistryTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun routesControlAndChannelWrapsAndRejectsOutsiders() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val registry = ConcordPlaneRegistry()
registry.registerControlPlanes(listOf(entry))
// A genesis control wrap routes to the CONTROL plane.
val controlWrap = community.genesisWraps.first()
assertTrue(registry.isKnownPlane(controlWrap.pubKey))
val routedControl = registry.route(controlWrap)
assertNotNull(routedControl)
assertEquals(ConcordPlaneKind.CONTROL, routedControl.plane.kind)
assertEquals(community.communityIdHex, routedControl.plane.communityId)
// After folding + registering channels, a channel message routes to CHANNEL.
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
registry.registerChannels(entry, state)
val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch)
val msgWrap = ConcordActions.buildChannelMessage(owner, channel, community.generalChannelIdHex, community.rootEpoch, "gm", 2L)
val routedMsg = registry.route(msgWrap)
assertNotNull(routedMsg)
assertEquals(ConcordPlaneKind.CHANNEL, routedMsg.plane.kind)
assertEquals(community.generalChannelIdHex, routedMsg.plane.channelId?.channelId)
assertEquals(ChatEvent.KIND, routedMsg.opened.rumor.kind)
assertEquals("gm", routedMsg.opened.rumor.content)
// A wrap from an unrelated plane (different community) is not ours.
val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example"))
assertNull(registry.route(outsider.genesisWraps.first()))
}
}
@@ -0,0 +1,129 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ConcordSessionManagerTest {
private val owner = NostrSignerInternal(KeyPair())
private fun entryFor(
community: NewConcordCommunity,
name: String,
) = ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = listOf("wss://r.example"),
name = name,
)
@Test
fun syncsFromFlowFoldsOnIngestAndAdvancesRevision() =
runTest {
val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example"))
val communities = MutableStateFlow(listOf(entryFor(alpha, "Alpha")))
val manager = ConcordSessionManager(communities, owner.pubKey, backgroundScope)
testScheduler.runCurrent()
// The joined community produced a session, and its control plane is in the subscribe set.
assertTrue(manager.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex))
val revAfterSync = manager.revision.value
assertTrue(revAfterSync > 0)
// Ingesting the genesis control wraps folds Alpha and advances the revision.
alpha.genesisWraps.forEach { assertTrue(manager.ingest(it)) }
testScheduler.runCurrent()
assertEquals(
"Alpha",
manager
.sessionFor(alpha.communityIdHex)
?.state
?.value
?.metadata
?.name,
)
assertTrue(manager.revision.value > revAfterSync)
// The folded #general channel plane is now part of the subscribe set.
val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch)
assertTrue(manager.subscribeAddresses().contains(general.publicKeyHex))
// Joining a second community via the flow creates its session too.
val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example"))
communities.value = listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta"))
testScheduler.runCurrent()
assertTrue(manager.subscribeAddresses().contains(beta.controlPlane.publicKeyHex))
// Alpha's fold survived the re-sync.
assertEquals(
"Alpha",
manager
.sessionFor(alpha.communityIdHex)
?.state
?.value
?.metadata
?.name,
)
}
@Test
fun exposesStreamKeysScopedToTheCommunityRelaysForNip42Auth() =
runTest {
val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example"))
val communities = MutableStateFlow(listOf(entryFor(alpha, "Alpha")))
val manager = ConcordSessionManager(communities, owner.pubKey, backgroundScope)
testScheduler.runCurrent()
val hosted = RelayUrlNormalizer.normalize("wss://r.example")
val elsewhere = RelayUrlNormalizer.normalize("wss://other.example")
// Before any fold, only the control-plane key must AUTH — and only on the community's relay.
val beforeFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() }
assertTrue(beforeFold.contains(alpha.controlPlane.secretKey.toHexKey()))
assertTrue(manager.streamAuthSecretsFor(elsewhere).isEmpty()) // relay-scoped
// After the Control Plane folds, the #general channel key joins the AUTH set.
alpha.genesisWraps.forEach { manager.ingest(it) }
testScheduler.runCurrent()
val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch)
val afterFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() }
assertTrue(afterFold.contains(alpha.controlPlane.secretKey.toHexKey()))
assertTrue(afterFold.contains(general.secretKey.toHexKey()))
assertFalse(manager.streamAuthSecretsFor(elsewhere).any { it.toHexKey() == general.secretKey.toHexKey() })
}
}
@@ -0,0 +1,105 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordSessionRegistryTest {
private val owner = NostrSignerInternal(KeyPair())
private fun entryFor(
community: NewConcordCommunity,
name: String,
) = ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = listOf("wss://r.example"),
name = name,
)
@Test
fun syncsSessionsRoutesWrapsAndDropsDepartedCommunities() =
runTest {
val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example"))
val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example"))
val captured = mutableListOf<Triple<String, String, com.vitorpamplona.quartz.nip01Core.core.Event>>()
val registry = ConcordSessionRegistry { communityId, channelIdHex, rumor -> captured += Triple(communityId, channelIdHex, rumor) }
// First sync creates a session for each joined community.
val created = registry.sync(listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta")), owner.pubKey)
assertEquals(setOf(alpha.communityIdHex, beta.communityIdHex), created)
assertNotNull(registry.sessionFor(alpha.communityIdHex))
assertNotNull(registry.sessionFor(beta.communityIdHex))
// Both control-plane addresses are in the subscribe set from the entries alone.
assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex))
assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex))
// A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL).
alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, registry.ingest(it)) }
val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value
assertEquals("Alpha", alphaState?.metadata?.name)
// After the fold, Alpha's #general channel plane joins the subscribe set.
val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch)
assertTrue(registry.subscribeAddresses().contains(general.publicKeyHex))
// A channel message decrypts and is emitted to the sink for Alpha's #general.
val msg = ConcordActions.buildChannelMessage(owner, general, alpha.generalChannelIdHex, alpha.rootEpoch, "gm", 2L)
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, registry.ingest(msg))
val general9 = captured.filter { it.first == alpha.communityIdHex && it.second == alpha.generalChannelIdHex && it.third.content == "gm" }
assertEquals(1, general9.size)
// A re-sync that keeps Alpha but drops Beta preserves Alpha's folded state and removes Beta.
val createdAgain = registry.sync(listOf(entryFor(alpha, "Alpha")), owner.pubKey)
assertTrue(createdAgain.isEmpty())
assertNotNull(registry.sessionFor(alpha.communityIdHex))
assertNull(registry.sessionFor(beta.communityIdHex))
assertEquals(
"Alpha",
registry
.sessionFor(alpha.communityIdHex)!!
.state.value
?.metadata
?.name,
)
// A wrap from an unknown community is routed nowhere.
val gamma = ConcordCommunityFactory.create(owner, "Gamma", createdAt = 1L, relays = listOf("wss://r.example"))
assertEquals(ConcordIngestOutcome.NOT_MINE, registry.ingest(gamma.genesisWraps.first()))
}
}
@@ -105,7 +105,7 @@ class DesktopAuthCoordinator(
RelayAuthenticator(
client = relayManager.client,
scope = scope,
signWithAllLoggedInUsers = { relayUrl, template ->
signWithAllLoggedInUsers = { relayUrl, template, _ ->
val signed = signWithPolicy(account, relayUrl, template, policy)
signed?.let { listOf(it) } ?: emptyList()
},
@@ -0,0 +1,88 @@
# Concord event layer → per-kind Event classes (nip88Polls structure)
## Why
The Concord quartz layer currently centralizes wire kinds in a single
`concord/events/ConcordKinds.kt` constant object and hand-rolls rumors with raw
string tags (see `cord03Channels/ChannelChat.kt`:
`RumorAssembler.assembleRumor(kind = ConcordKinds.MESSAGE, tags = arrayOf(arrayOf("q", …)))`).
Two problems:
1. **Duplicates standard Nostr kinds.** `ConcordKinds.MESSAGE=9`, `REACTION=7`,
`DELETE=5`, `COMMENT=1111`, `EDIT=3302` shadow `ChatEvent.KIND`,
`ReactionEvent.KIND`, `DeletionEvent.KIND`, `CommentEvent.KIND`. Concord chat
rumors *are* those standard events (they already parse back as `ChatEvent`
etc. on read) — the build side should reuse the classes, not re-derive by
number.
2. **No per-event structure.** Every other protocol in quartz gives each event
kind a package: `XxxEvent.kt` (the `Event` subclass + `build`), plus
`TagArrayBuilderExt.kt` / `TagArrayExt.kt` and a `tags/` folder of typed tag
classes (`nip88Polls/poll/…` is the reference). Concord instead has loose
builders (`ChannelChat`, `ControlEditionBuilder`) and stringly-typed tags.
Target: match the `nip88Polls` shape. Reuse standard events where the protocol
uses standard kinds; give each genuinely-Concord kind its own package.
## Reuse standard events (chat plane, CORD-03)
A Concord chat rumor is a standard event + a `["channel", id]` + `["epoch", n]`
binding. Introduce a binding-tag package and reuse the standard builders:
- `cord03Channels/tags/ChannelTag.kt`, `tags/EpochTag.kt` — typed tags.
- `cord03Channels/TagArrayBuilderExt.kt``channel(id)`, `epoch(n)` on the DSL.
- `cord03Channels/TagArrayExt.kt``channelId()`, `epoch()`, `isBoundTo(...)`.
| rumor | reuse | binding |
|---|---|---|
| message (9) | `ChatEvent.build` | `channel` + `epoch` |
| reply (9 + q) | `ChatEvent.build` + `q`/`p` | `channel` + `epoch` |
| reaction (7) | `ReactionEvent.build` | `channel` + `epoch` |
| delete (5) | `DeletionEvent.build` | `channel` + `epoch` |
`ChannelChat` keeps its public API (returns unsigned rumors via `RumorAssembler`)
but builds tags from the standard event's DSL + the binding ext. Delete
`ConcordKinds.MESSAGE/REACTION/DELETE/COMMENT/EDIT`.
**Interop guard:** the exact on-wire tags must stay byte-identical to today's
output (Armada compat). Keep `["q", parentId]` + `["p", parentAuthor]` for
replies and `["e"/"p"/"k"]` for reactions — do not switch to `QEventTag`'s
3-element form. Verify with `ConcordPlaneRegistryTest` + an amy↔Armada round-trip.
## New per-kind Event classes (genuinely Concord)
Each gets `concord/<cordXX>/<name>/XxxEvent.kt` + `TagArrayBuilderExt` +
`TagArrayExt` + `tags/`:
| kind | event | CORD |
|---|---|---|
| 3308 | `ControlEditionEvent` (from `ControlEdition`/`ControlEditionBuilder`) | 02/04/06 |
| 3303 | `RekeyEvent` | 06 |
| 3306 / 3309 / 3312 | `GuestbookJoinLeaveEvent` / `KickEvent` / `SnapshotEvent` | 02 |
| 3313 | `DirectInviteEvent` | 05 |
| 23311 / 23313 | `TypingEvent` / `VoicePresenceEvent` | 03/07 |
| 3310 | `WebxdcEvent` | 03 |
| 33301 | `InviteBundleEvent` (from `ConcordInviteBundle`) | 05 |
| 13303 | `InviteListEvent` | 05 |
| 20013 / 20014 / 1059 / 21059 | envelope seals + inverted wrap | 01 |
`ConcordCommunityListEvent` (13302) is already an `Event` class (now a
`BaseReplaceableEvent`) — keep, just move under a per-kind package if desired.
After the move, `ConcordKinds` retains only the truly-Concord kinds (envelope,
control, rekey, guestbook, invites, voice), not the standard-Nostr aliases.
## Sequencing (incremental, compile + interop-test each)
1. ✅ Chat plane reuse — `ChatEvent`/`ReactionEvent` + `cord03Channels/tags` +
ext; dropped `MESSAGE/REACTION/DELETE/COMMENT` aliases. (commit c2cbd602)
2. ✅ Control plane 3308 → `cord04Roles/control/ControlEditionEvent` package +
`tags/` (vsk/eid/ev/ep/vac) + ext; `EventFactory` registers 3308. (commit dc2abc0e)
3. Invites: ✅ 33301 bundle → `cord05Invites/bundle/ConcordInviteBundleEvent`
(addressable, reuses `DTag` + `VskTag`; registered in `EventFactory`). Remaining:
3313 direct-invite → `ConcordDirectInviteEvent` (the rumor has empty tags; the
`p`/`k` index lives on the giftwrap). 13303 invite-list has no implementation yet
(a bare `ConcordKinds` constant) — build it when the private-invite feature lands.
4. Guestbook (3306/3309/3312) + voice (23313/23311) + rekey (3303). **← next**
5. Envelope seals (20013/20014) / wrap (1059/21059).
6. Shrink `ConcordKinds` to only kinds without their own event class; audit
`EventFactory` coverage.
@@ -0,0 +1,141 @@
/*
* 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.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* A freshly created Concord community: its self-certifying identity and access
* secrets, plus the genesis Control Plane wraps to publish and the equivalent
* editions to fold locally.
*/
class NewConcordCommunity(
val communityId: ByteArray,
val ownerPubKey: String,
val ownerSalt: ByteArray,
val communityRoot: ByteArray,
val rootEpoch: Long,
val generalChannelId: ByteArray,
val controlPlane: GroupKey,
/** The kind-1059 control-plane wraps to publish (metadata + #general). */
val genesisWraps: List<Event>,
/** The same editions as parsed [ControlEdition]s, for immediate local folding. */
val genesisEditions: List<ControlEdition>,
) {
val communityIdHex: String get() = communityId.toHexKey()
val generalChannelIdHex: String get() = generalChannelId.toHexKey()
}
/**
* Creates new Concord communities (CORD-02 Genesis).
*
* `create` mints a random `owner_salt`, derives the self-certifying
* `community_id = sha256("concord/community" owner salt)`, generates an
* independent random `community_root` (so access can rotate while identity stays
* fixed), and emits exactly two owner-signed genesis editions the community
* metadata and a public `#general` channel as plaintext-seal wraps on the
* Control Plane at epoch 0.
*/
object ConcordCommunityFactory {
const val GENERAL_CHANNEL_NAME = "general"
suspend fun create(
ownerSigner: NostrSigner,
name: String,
createdAt: Long,
description: String? = null,
relays: List<String> = emptyList(),
icon: ImagePointer? = null,
): NewConcordCommunity {
val ownerXOnly = ownerSigner.pubKey.hexToByteArray()
val ownerSalt = ConcordKeyDerivation.newOwnerSalt()
val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt)
val communityRoot = RandomInstance.bytes(32)
val generalChannelId = RandomInstance.bytes(32)
val rootEpoch = 0L
val controlPlane = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch)
val metadataJson =
ConcordJson.instance.encodeToString(
MetadataEntity.serializer(),
MetadataEntity(name = name, icon = icon, description = description, relays = relays),
)
val channelJson =
ConcordJson.instance.encodeToString(
ChannelEntity.serializer(),
ChannelEntity(name = GENERAL_CHANNEL_NAME, private = false),
)
val metadataRumor =
ControlEditionBuilder.rumor(
authorPubKey = ownerSigner.pubKey,
entityKind = ControlEntityKind.METADATA,
entityId = communityId, // metadata eid == community id
version = 0,
prevHash = null,
content = metadataJson,
createdAt = createdAt,
)
val channelRumor =
ControlEditionBuilder.rumor(
authorPubKey = ownerSigner.pubKey,
entityKind = ControlEntityKind.CHANNEL,
entityId = generalChannelId, // channel eid == channel id
version = 0,
prevHash = null,
content = channelJson,
createdAt = createdAt,
)
// Control Plane uses plaintext (20014) seals so signatures survive re-encryption across epochs.
val metadataWrap = ConcordStreamEnvelope.wrap(metadataRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt)
val channelWrap = ConcordStreamEnvelope.wrap(channelRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt)
return NewConcordCommunity(
communityId = communityId,
ownerPubKey = ownerSigner.pubKey,
ownerSalt = ownerSalt,
communityRoot = communityRoot,
rootEpoch = rootEpoch,
generalChannelId = generalChannelId,
controlPlane = controlPlane,
genesisWraps = listOf(metadataWrap, channelWrap),
genesisEditions =
listOfNotNull(
ControlEdition.fromRumor(metadataRumor),
ControlEdition.fromRumor(channelRumor),
),
)
}
}
@@ -0,0 +1,245 @@
/*
* 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.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A past root key for a specific epoch, kept so historical channel keys stay derivable. */
@Serializable
class HeldRoot(
val epoch: Long,
val key: String,
)
/** A private channel's delivered key at a given epoch (for private channels the member can read). */
@Serializable
class PrivateChannelKey(
val channelId: String,
val key: String,
val epoch: Long,
val name: String = "",
)
/**
* One joined Concord community in the member's private list. Carries everything
* needed to re-derive the community's planes on any device: identity ([id],
* [owner], [ownerSalt]), the current access [root] at [rootEpoch] plus past
* [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached
* display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks
* liveness against tombstones.
*/
@Serializable
class ConcordCommunityListEntry(
val id: String,
val owner: String,
val ownerSalt: String,
val root: String,
val rootEpoch: Long = 0,
val heldRoots: List<HeldRoot> = emptyList(),
val privateChannels: List<PrivateChannelKey> = emptyList(),
val relays: List<String> = emptyList(),
val name: String = "",
val addedAt: Long = 0,
)
/**
* The member's private, self-encrypted list of joined Concord communities
* (kind [ConcordCommunityListEvent.KIND] = 13302, CORD-05) the NIP-51 analog that
* lets a client return to the groups the user signed up for. Replaceable and
* NIP-44-encrypted to the member's own key, so relays store only ciphertext.
*
* The plaintext document is wire-compatible with Soapbox Armada's `communityList.ts`:
* `{ "entries": [ { "community_id", "seed": JoinMaterial, "current": JoinMaterial,
* "added_at" } ], "tombstones": [ { "community_id", "removed_at" } ] }`, where
* [JoinMaterialWire] is the snake_case per-snapshot key bundle. Liveness is derived
* an entry is dropped only when a later tombstone removes it and each entry keeps a
* [CommunityListEntryWire.seed] (backfill anchor) plus [CommunityListEntryWire.current]
* (latest) snapshot; we hydrate from `current`, falling back to `seed`.
*
* (Channels are not listed here beyond their private keys: once the [root] is held,
* folding the Control Plane yields the community's channels.)
*/
object ConcordCommunityList {
// ---- wire DTOs (snake_case, Armada communityList.ts) ----------------------
@Serializable
private class WireChannel(
val id: String,
val key: String,
val epoch: Long,
val name: String = "",
)
@Serializable
private class WireHeldRoot(
val epoch: Long,
val key: String,
)
@Serializable
private class JoinMaterialWire(
@SerialName("community_id") val communityId: String,
val owner: String,
@SerialName("owner_salt") val ownerSalt: String,
@SerialName("community_root") val communityRoot: String,
@SerialName("root_epoch") val rootEpoch: Long,
val channels: List<WireChannel> = emptyList(),
val relays: List<String> = emptyList(),
val name: String = "",
@SerialName("held_roots") val heldRoots: List<WireHeldRoot> = emptyList(),
val refounder: String? = null,
)
@Serializable
private class CommunityListEntryWire(
@SerialName("community_id") val communityId: String,
val seed: JoinMaterialWire? = null,
val current: JoinMaterialWire? = null,
@SerialName("added_at") val addedAt: Long = 0,
)
@Serializable
private class CommunityTombstoneWire(
@SerialName("community_id") val communityId: String,
@SerialName("removed_at") val removedAt: Long = 0,
)
@Serializable
private class CommunityListDoc(
val entries: List<CommunityListEntryWire> = emptyList(),
val tombstones: List<CommunityTombstoneWire> = emptyList(),
)
private fun ConcordCommunityListEntry.toJoinMaterial() =
JoinMaterialWire(
communityId = id,
owner = owner,
ownerSalt = ownerSalt,
communityRoot = root,
rootEpoch = rootEpoch,
channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name) },
relays = relays,
name = name,
heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) },
)
private fun JoinMaterialWire.toEntry(addedAt: Long) =
ConcordCommunityListEntry(
id = communityId,
owner = owner,
ownerSalt = ownerSalt,
root = communityRoot,
rootEpoch = rootEpoch,
heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) },
privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) },
relays = relays,
name = name,
addedAt = addedAt,
)
// ---- build / codec --------------------------------------------------------
/** Builds the encrypted kind-13302 list event from [entries], signed by [signer]. */
suspend fun build(
signer: NostrSigner,
entries: List<ConcordCommunityListEntry>,
createdAt: Long,
): Event {
val content = signer.nip44Encrypt(encode(entries), signer.pubKey)
return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content)
}
/** Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted. */
fun encode(entries: List<ConcordCommunityListEntry>): String {
val doc =
CommunityListDoc(
entries =
entries.map { e ->
val jm = e.toJoinMaterial()
CommunityListEntryWire(
communityId = e.id,
seed = jm,
current = jm,
addedAt = e.addedAt,
)
},
tombstones = emptyList(),
)
return ConcordJson.instance.encodeToString(CommunityListDoc.serializer(), doc)
}
/**
* Parses the decrypted plaintext JSON document back into live entries, or empty on
* failure. An entry is live unless a tombstone for the same community removed it
* strictly after it was added; hydration prefers `current`, falling back to `seed`.
*/
fun decode(json: String): List<ConcordCommunityListEntry> =
try {
val doc = ConcordJson.instance.decodeFromString(CommunityListDoc.serializer(), json)
val latestRemoval = HashMap<String, Long>()
for (t in doc.tombstones) {
val prev = latestRemoval[t.communityId]
if (prev == null || t.removedAt > prev) latestRemoval[t.communityId] = t.removedAt
}
doc.entries.mapNotNull { e ->
val removedAt = latestRemoval[e.communityId]
if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null
(e.current ?: e.seed)?.toEntry(e.addedAt)
}
} catch (_: Exception) {
emptyList()
}
/** Decrypts and parses a kind-13302 list event with [signer], or empty on failure. */
suspend fun parse(
event: Event,
signer: NostrSigner,
): List<ConcordCommunityListEntry> {
if (event.kind != ConcordCommunityListEvent.KIND) return emptyList()
return try {
decode(signer.nip44Decrypt(event.content, signer.pubKey))
} catch (_: Exception) {
emptyList()
}
}
/**
* Merges two decrypted lists (e.g. from two devices), keeping one entry per
* community id. When both hold the same community, the one with the higher
* [ConcordCommunityListEntry.rootEpoch] wins so the freshest access key
* survives.
*/
fun merge(
a: List<ConcordCommunityListEntry>,
b: List<ConcordCommunityListEntry>,
): List<ConcordCommunityListEntry> {
val byId = LinkedHashMap<String, ConcordCommunityListEntry>()
for (e in a + b) {
val existing = byId[e.id]
if (existing == null || e.rootEpoch > existing.rootEpoch) byId[e.id] = e
}
return byId.values.toList()
}
}
@@ -0,0 +1,78 @@
/*
* 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.quartz.concord.cord02Community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* The member's private, self-encrypted list of joined Concord communities (kind
* 13302, CORD-05). A replaceable event whose
* `content` is the NIP-44 self-encryption of the [ConcordCommunityListEntry] JSON
* including each community's secrets (`community_root`, salt, epoch,
* private-channel keys), so a single event both syncs membership across devices
* and carries the keys needed to re-derive every plane.
*
* This is the Concord analog of NIP-29's kind-10009 `SimpleGroupListEvent` and
* NIP-17's chatroom home base; it is what the Account's `ConcordChannelListState`
* observes. Only the owner's key decrypts it, so relays store ciphertext.
*/
@Immutable
class ConcordCommunityListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
/** Decrypts this list's entries with [signer], or empty on failure / wrong key. */
suspend fun decrypt(signer: NostrSigner): List<ConcordCommunityListEntry> =
try {
ConcordCommunityList.decode(signer.nip44Decrypt(content, signer.pubKey))
} catch (_: Exception) {
emptyList()
}
companion object {
const val KIND = 13302
const val ALT = "Private list of joined Concord communities"
/** The replaceable coordinate for a member's list: `(13302, pubkey, "")`. */
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "")
/** Builds a signed, self-encrypted list event from [entries]. */
suspend fun create(
signer: NostrSigner,
entries: List<ConcordCommunityListEntry>,
createdAt: Long = TimeUtils.now(),
): ConcordCommunityListEvent {
val content = signer.nip44Encrypt(ConcordCommunityList.encode(entries), signer.pubKey)
return signer.sign(createdAt, KIND, emptyArray(), content)
}
}
}
@@ -0,0 +1,120 @@
/*
* 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.quartz.concord.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
/** A channel id paired with its current folded definition. */
class ConcordChannel(
val channelIdHex: String,
val definition: ChannelEntity,
)
/**
* The current, folded state of a Concord community's Control Plane (CORD-02).
*
* Produced by [fold] from the set of decrypted, verified control editions plus
* the community's known [ownerPubKey]. It exposes the community [metadata], the
* live (non-deleted) [channels], the live [roles], the owner-rooted [authority]
* resolver, and whether the community has been [dissolved].
*
* "Every member keeps the entire Control Plane in sync — it is small and must
* stay complete." Recompute this whenever the known editions change.
*/
class ConcordCommunityState(
val ownerPubKey: String,
val metadata: MetadataEntity?,
val channels: Map<String, ConcordChannel>,
val roles: Map<String, RoleEntity>,
val authority: AuthorityResolver,
val dissolved: Boolean,
) {
companion object {
fun fold(
editions: Collection<ControlEdition>,
ownerPubKey: String,
): ConcordCommunityState {
val heads = EditionFold.fold(editions).values
// Resolve authority from the FULL edition set (not the structural heads): the resolver
// folds each role/grant chain through authorized editions only, so a rogue higher-version
// edition can't supersede a legit one before authority is even judged.
val authority = AuthorityResolver.resolve(editions, ownerPubKey)
// CORD-04 §1: "an edition whose signer isn't authorized is dropped." Authority is
// owner-rooted (the AuthorityResolver resolves it from the owner outward via the grant
// fixpoint), so gating each managed entity by its required permission BEFORE the
// structural fold filters out spoofed editions — e.g. a decoy metadata genesis minted by
// an unprivileged key — instead of letting a higher-version forgery win the chain. The
// permission check also excludes banned authors (hasPermission is false for a banned npub).
fun editorsWith(
kind: ControlEntityKind,
bit: Int,
): List<ControlEdition> =
editions.filter {
it.entityKind == kind && (authority.isOwner(it.author) || authority.hasPermission(it.author, bit))
}
// Metadata is one entity (== community id), gated by MANAGE_METADATA. Fold only the
// authorized editions, then take the highest-version head (guarding against strays).
val metadata =
EditionFold
.fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA))
.values
.maxByOrNull { it.version }
?.let { ConcordJson.decodeOrNull<MetadataEntity>(it.content) }
// Channels are gated by MANAGE_CHANNELS. Fold each channel entity from its authorized
// editions only, dropping the tombstoned ones.
val channels = LinkedHashMap<String, ConcordChannel>()
for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS)).values) {
val def = ConcordJson.decodeOrNull<ChannelEntity>(head.content) ?: continue
if (def.deleted) continue
channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def)
}
// Role definitions come from the authority-gated fold (not the raw structural heads): a
// rogue can mint a higher-version edition on a legit role's coordinate (e.g. marking the
// Admin role deleted) that would win a structural fold and corrupt the displayed roster,
// so we take the roles the AuthorityResolver actually accepted from the owner outward.
val roles = authority.roles()
// Dissolution is owner-only — a rogue tombstone must not appear to kill the community.
val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED && authority.isOwner(it.author) }
return ConcordCommunityState(
ownerPubKey = ownerPubKey.lowercase(),
metadata = metadata,
channels = channels,
roles = roles,
authority = authority,
dissolved = dissolved,
)
}
}
}
@@ -0,0 +1,129 @@
/*
* 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.quartz.concord.cord02Community
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
/** A self-signed membership motion on the Guestbook Plane. */
enum class GuestbookAction(
val wire: String,
) {
JOIN("join"),
LEAVE("leave"),
;
companion object {
fun of(wire: String) = entries.firstOrNull { it.wire == wire }
}
}
/** A parsed Guestbook join/leave: who ([member]), what ([action]), and invite attribution. */
class GuestbookEntry(
val member: HexKey,
val action: GuestbookAction,
val createdAt: Long,
val inviteCreator: HexKey?,
val inviteLabel: String?,
)
/**
* The Guestbook Plane (CORD-02): self-signed Joins and Leaves plus authorized
* Kicks that track membership motion. It is **off-consensus** nothing in the
* Control or Chat planes depends on it so it is best-effort presence, not
* authority.
*
* Rumor builders here are unsigned; seal + wrap them onto the community's
* Guestbook plane with
* [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope].
*/
object Guestbook {
/** Guestbook rumor kinds (CORD-02): self-signed join/leave and authorized kick. */
const val KIND_JOIN_LEAVE = 3306
const val KIND_KICK = 3309
const val TAG_MS = "ms"
const val TAG_INVITE = "invite"
const val TAG_P = "p"
/** A self-signed join (kind 3306), optionally attributing the invite used. */
fun join(
memberPubKey: HexKey,
createdAt: Long,
subMs: Int? = null,
inviteCreator: HexKey? = null,
inviteLabel: String? = null,
): Event = motion(memberPubKey, GuestbookAction.JOIN, createdAt, subMs, inviteCreator, inviteLabel)
/** A self-signed leave (kind 3306). */
fun leave(
memberPubKey: HexKey,
createdAt: Long,
subMs: Int? = null,
): Event = motion(memberPubKey, GuestbookAction.LEAVE, createdAt, subMs, null, null)
private fun motion(
memberPubKey: HexKey,
action: GuestbookAction,
createdAt: Long,
subMs: Int?,
inviteCreator: HexKey?,
inviteLabel: String?,
): Event {
val tags = ArrayList<Array<String>>(2)
if (subMs != null) tags.add(arrayOf(TAG_MS, subMs.toString()))
if (inviteCreator != null) {
tags.add(if (inviteLabel != null) arrayOf(TAG_INVITE, inviteCreator, inviteLabel) else arrayOf(TAG_INVITE, inviteCreator))
}
return RumorAssembler.assembleRumor(memberPubKey, createdAt, KIND_JOIN_LEAVE, tags.toTypedArray(), action.wire)
}
/**
* An authorized Kick (kind 3309) directing [target] to leave. A Kick is only
* honored by clients when its signer holds [com.vitorpamplona.quartz.concord
* .cord04Roles.ConcordPermissions.KICK] and outranks the target a Kick from
* a non-KICK holder is dropped (CORD-04 §The Three Removals).
*/
fun kick(
actorPubKey: HexKey,
target: HexKey,
createdAt: Long,
): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, KIND_KICK, arrayOf(arrayOf(TAG_P, target)), "")
/** Parses a Guestbook join/leave rumor, or null if it is not one. */
fun parse(rumor: Event): GuestbookEntry? {
if (rumor.kind != KIND_JOIN_LEAVE) return null
val action = GuestbookAction.of(rumor.content) ?: return null
val invite = rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE }
return GuestbookEntry(
member = rumor.pubKey,
action = action,
createdAt = rumor.createdAt,
inviteCreator = invite?.getOrNull(1),
inviteLabel = invite?.getOrNull(2),
)
}
/** The kick target's pubkey from a kind-3309 rumor, or null. */
fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == KIND_KICK) rumor.tags.firstTagValue(TAG_P) else null
}

Some files were not shown because too many files have changed in this diff Show More