mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
docs(concord): add mobile integration plan mirroring NIP-29 relay groups
Blueprint for the Android app layer, cloning the just-merged NIP-29 relay-groups touch points with Concord equivalents: commons ConcordChannel + kind-13302 ConcordChannelListState, Account/LocalCache wiring, the 6-way Messages-inbox concatenation + synthetic server row (chip opens the channel), the reused NIP-28 ChannelView chat screens, nav routes, a GitRepositories-style discovery feed, and notification routing + on-plane zaps/likes. Documents the one structural difference from NIP-29: Concord communities are E2EE (plane-pubkey addressing, no public relay-signed metadata), so discovery surfaces public invite links rather than browsable metadata. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user