mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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.
|
||||
+1
-1
@@ -97,7 +97,7 @@ class EventSyncTest {
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
signWithAllLoggedInUsers = { _, authTemplate, _ ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 (~10–20s 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)
|
||||
}
|
||||
|
||||
+59
-5
@@ -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
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+7
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+74
@@ -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)
|
||||
}
|
||||
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
+9
@@ -190,6 +190,15 @@ class AccountFeedContentStates(
|
||||
}
|
||||
}
|
||||
|
||||
// Same for the Concord view mode (inline channels vs one row per community).
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.settings.concordViewMode
|
||||
.drop(1)
|
||||
.collect {
|
||||
dmKnown.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Pinning/unpinning a room only changes sort order, not membership, so no
|
||||
// chat event flows through LocalCache. Force a rebuild to re-sort. This
|
||||
// also fires when pins arrive via the synced AppSpecificData event.
|
||||
|
||||
+68
-1
@@ -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,
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+17
@@ -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 (20000–29999) 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)
|
||||
|
||||
+76
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+252
@@ -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() }
|
||||
}
|
||||
}
|
||||
+423
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+570
@@ -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,
|
||||
)
|
||||
}
|
||||
+89
@@ -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
|
||||
}
|
||||
+81
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+550
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
+117
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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"
|
||||
+95
@@ -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) }) { _, _ -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+337
@@ -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
|
||||
}
|
||||
+369
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -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
|
||||
}
|
||||
+191
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
+194
@@ -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()
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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()
|
||||
|
||||
+32
@@ -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>()
|
||||
|
||||
+5
@@ -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 ->
|
||||
|
||||
+156
-7
@@ -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
|
||||
|
||||
+108
-4
@@ -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
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A synthetic Messages-list row that collapses ALL of a user's channels in one Concord
|
||||
* [communityId] into a single entry — the "grouped by community" view mode
|
||||
* ([com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode.GROUPED]). It is the
|
||||
* Concord analog of [RelayGroupServerRoomNote] (NIP-29 groups by host relay; Concord groups
|
||||
* by community).
|
||||
*
|
||||
* It is not a real event: [event] stays null and [createdAt] mirrors [newestMessage] (the
|
||||
* newest decrypted message across that community's channels) so the row interleaves with DMs
|
||||
* and other chats by recency. Tapping it opens the community's channel list. Exactly one
|
||||
* instance exists per community — keyed by a stable [idHex] so feed diffing and the LazyColumn
|
||||
* treat it as the same row across refreshes.
|
||||
*/
|
||||
class ConcordServerRoomNote(
|
||||
val communityId: HexKey,
|
||||
val newestMessage: Note?,
|
||||
) : Note(idFor(communityId)) {
|
||||
override fun createdAt(): Long? = newestMessage?.createdAt()
|
||||
|
||||
companion object {
|
||||
fun idFor(communityId: HexKey): HexKey = "concordserver-$communityId"
|
||||
}
|
||||
}
|
||||
+84
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-7
@@ -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()
|
||||
|
||||
+22
-3
@@ -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 -> {
|
||||
|
||||
+25
-3
@@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -70,6 +71,8 @@ fun MessagesSettingsScreen(
|
||||
) {
|
||||
val mode by accountViewModel.account.settings.relayGroupViewMode
|
||||
.collectAsStateWithLifecycle()
|
||||
val concordMode by accountViewModel.account.settings.concordViewMode
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -87,24 +90,43 @@ fun MessagesSettingsScreen(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
|
||||
RelayGroupViewModeOption(
|
||||
ViewModeOption(
|
||||
title = stringRes(R.string.relay_group_view_inline),
|
||||
description = stringRes(R.string.relay_group_view_inline_desc),
|
||||
selected = mode == RelayGroupViewMode.INLINE,
|
||||
onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) },
|
||||
)
|
||||
RelayGroupViewModeOption(
|
||||
ViewModeOption(
|
||||
title = stringRes(R.string.relay_group_view_grouped),
|
||||
description = stringRes(R.string.relay_group_view_grouped_desc),
|
||||
selected = mode == RelayGroupViewMode.GROUPED,
|
||||
onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) },
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.concord_view_mode_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
|
||||
ViewModeOption(
|
||||
title = stringRes(R.string.concord_view_inline),
|
||||
description = stringRes(R.string.concord_view_inline_desc),
|
||||
selected = concordMode == ConcordViewMode.INLINE,
|
||||
onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.INLINE) },
|
||||
)
|
||||
ViewModeOption(
|
||||
title = stringRes(R.string.concord_view_grouped),
|
||||
description = stringRes(R.string.concord_view_grouped_desc),
|
||||
selected = concordMode == ConcordViewMode.GROUPED,
|
||||
onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.GROUPED) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupViewModeOption(
|
||||
private fun ViewModeOption(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: Boolean,
|
||||
|
||||
+1
@@ -1042,6 +1042,7 @@ private fun FullBleedNoteCompose(
|
||||
makeItShort = false,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = 3,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
|
||||
@@ -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
|
||||
|
||||
+147
@@ -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) }
|
||||
}
|
||||
}
|
||||
+398
@@ -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)
|
||||
}
|
||||
+205
@@ -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)
|
||||
}
|
||||
}
|
||||
+149
@@ -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) }
|
||||
}
|
||||
+158
@@ -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()}"
|
||||
}
|
||||
}
|
||||
+140
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+388
@@ -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
|
||||
}
|
||||
}
|
||||
+87
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -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() }
|
||||
}
|
||||
+165
@@ -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()
|
||||
}
|
||||
}
|
||||
+115
@@ -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() }
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -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)
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+36
@@ -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,
|
||||
}
|
||||
+135
@@ -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') }
|
||||
}
|
||||
+97
@@ -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))
|
||||
}
|
||||
}
|
||||
+107
@@ -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))
|
||||
}
|
||||
}
|
||||
+109
@@ -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()))
|
||||
}
|
||||
}
|
||||
+73
@@ -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())
|
||||
}
|
||||
}
|
||||
+83
@@ -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()))
|
||||
}
|
||||
}
|
||||
+129
@@ -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() })
|
||||
}
|
||||
}
|
||||
+105
@@ -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()))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
+141
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+245
@@ -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()
|
||||
}
|
||||
}
|
||||
+78
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -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
Reference in New Issue
Block a user