Merge pull request #3514 from vitorpamplona/claude/armada-nip29-integration-lwqard

Relay Groups: NIP-29 relay-based group chat (Armada interop)
This commit is contained in:
Vitor Pamplona
2026-07-10 13:03:48 -04:00
committed by GitHub
123 changed files with 11311 additions and 52 deletions
@@ -0,0 +1,538 @@
# NIP-29 Relay-Based Groups — Deep Study of Armada + Amethyst Integration Plan
**Date:** 2026-07-07
**Status:** Research / design study (no code yet)
**Reference client studied:** [Armada](https://gitlab.com/soapbox-pub/armada) (Soapbox), commit at HEAD of `main`
**Spec:** [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md)
This document is a deep study of how **Armada** — a Discord-style NIP-29 client
by Soapbox — creates events, manages chat, displays information, and handles
invites/joins/leaves, followed by a concrete plan for bringing NIP-29 into
Amethyst. It is written to answer "how does relay-based group chat actually work
in a shipping client, and where does it slot into Amethyst's existing chat
stack."
---
## 0. TL;DR for the impatient
- **NIP-29 groups live on ONE relay.** A group is addressed by the pair
`(relayUrl, groupId)`. The relay is the source of truth — it signs the group's
metadata/membership/roles and enforces who may write. This is fundamentally
different from Nostr's usual "publish everywhere" model.
- **Armada models it as Discord:** a **server = a relay**, a **channel = a
NIP-29 group**. The far-left rail is a list of relays; picking one shows that
relay's channels; picking a channel shows its kind-9 message timeline.
- **The user's home base is kind 10009** (NIP-51 "simple groups list"): it stores
both the relays they've added (`r` tags) and the groups they've joined
(`group` tags), privately (NIP-44 encrypted to self).
- **Amethyst is already ~70% wired for this at the protocol layer.** Quartz has a
`nip29RelayGroups` package (metadata/moderation/request events) **and** a
`nip43RelayMembers` package (the relay-level membership handshake Armada uses).
The main protocol gap is the **kind-9 group chat *message* event** itself.
- **Amethyst also already has the perfect UX analog:** the `ephemChat` feature
(NIP-C7, kind 23333) is a *relay-scoped* chat room identified by a
`RoomId(id, relayUrl)` — the exact shape of a NIP-29 group address — with a
full channel/screen/join/leave UI under
`amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/`. NIP-29 is a
managed, moderated sibling of that feature.
---
## 1. NIP-29 protocol primer (the parts that matter)
### 1.1 The addressing model
A group is **not** a global object. It exists on a specific relay, and its id is
only unique *within that relay*. So every reference to a group is the pair:
```
(relay websocket URL, group id)
```
The group id is a short opaque string (Armada mints 8 random bytes → 16 hex
chars). Everything that happens "in" the group is tagged with `["h", "<groupId>"]`
and published **only** to that relay.
### 1.2 Event kinds
| Kind | Name | Signed by | Purpose |
|------|------|-----------|---------|
| **9** | group chat message | user | the actual chat line (has `["h", groupId]`) |
| 11 | group thread/forum post | user | long-form/threaded root |
| 1111 | NIP-22 comment | user | threaded reply to a chat message |
| 7 | reaction | user | emoji reaction (scoped by `h`) |
| 1068 | poll (NIP-88) | user | poll posted into the timeline |
| **9000** | put-user | user (admin) | add member / set roles |
| **9001** | remove-user | user (admin) | remove member |
| **9002** | edit-metadata | user (admin) | change name/about/picture/flags |
| 9005 | delete-event | user (admin) | moderator delete a message |
| **9007** | create-group | user (admin) | create a new group |
| 9008 | delete-group | user (admin) | delete a group |
| **9009** | create-invite | user (admin) | mint an invite code |
| **9021** | join-request | user | ask to join (optional `code`) |
| **9022** | leave-request | user | leave the group |
| **39000** | group metadata | **relay** | name, picture, about, flags (addressable, `d`=groupId) |
| **39001** | group admins | **relay** | `["p", pubkey, role…]` list |
| **39002** | group members | **relay** | `["p", pubkey]` list |
| **39003** | group roles | **relay** | `["role", name, desc]` definitions |
| 39004 | live AV participants | **relay** | LiveKit room presence (Armada extension use) |
The **9xxx** events are user-authored **requests/commands**; the relay validates
the author's role and, if accepted, updates the group state and re-emits the
**39xxx** relay-signed snapshots. **Clients read 39xxx, write 9xxx.**
### 1.3 Group metadata flags (kind 39000 tags)
`name`, `picture`, `about` carry display data. Boolean status is presence-based:
- `private` — only members can **read** (relay gates reads behind NIP-42 AUTH).
- `restricted`* — only members can **write**.
- `closed` — join requests are ignored; you need an invite code.
- `hidden` — metadata hidden from non-members.
- `livekit` — group has an AV room.
- `supported_kinds` — whitelist of accepted kinds (absent = all).
The antonyms are `public` / `open` (Armada's `edit-metadata` emits the antonym
tag to *clear* `private`/`closed`; see §2.4).
### 1.4 The `previous` tag (timeline references) — and why Armada drops it
NIP-29 defines an optional `["previous", <id-prefix>, …]` tag: each event should
reference the first-8-chars of several of the group's recent events so a relay
can reject events that were composed against a *forked* view of the timeline
(anti-context-spam). **Armada deliberately does not emit it** — see the
verbatim rationale in `ChatComposer.buildMessageTags`:
> relay29's `CheckPreviousTag` rejects any event whose first `previous` ref isn't
> in the group's in-memory last-50 ring. We can only pick refs from a local (and
> own-excluded) message snapshot, which routinely drifts out of that window —
> especially when replying to older messages — causing the relay to silently drop
> legitimate messages/replies. `previous` is optional in NIP-29 and only guards
> against relay-fork attacks, which don't apply to this single-host-per-group
> deployment.
**Takeaway for Amethyst:** Quartz *has* a `PreviousTag` class, but a
single-host-per-group deployment does not need it, and emitting it naively causes
silent drops. Start without it (like Armada); only add it if targeting a relay
that enforces it.
---
## 2. How Armada creates events
Armada's whole NIP-29 protocol layer is ~770 lines in
`client/src/lib/nip29.ts` (constants, parsers, tag builders), driven by a set of
TanStack-Query hooks in `client/src/hooks/`. Every write goes through a single
`useNostrPublish` mutation whose `relay` option **pins the event to the group's
host relay** — this is the linchpin of the whole design.
### 2.1 The publish chokepoint (`useNostrPublish.ts`)
```ts
// EventTemplate has an optional `relay?: string`.
// When set, publish ONLY to this relay (NIP-29 group traffic must stay on
// the group's host server). When omitted, the event goes to all app relays.
if (relay) {
await nostr.relay(relay).event(event, …); // single-relay send
} else {
await nostr.event(event, …); // fan out to app relays
}
```
It also: adds a NIP-89 `["client", APP_NAME]` tag, adds `published_at` for
replaceable kinds, stores the signed event locally *before* the network call (so
optimistic UI + offline retry work), and normalizes relay rejection strings for
toasts (`relayRejectionMessage`).
**Every NIP-29 write in Armada passes `relay: relayUrl`.** Group/channel events
*never* touch the general app relays. This is the single most important
invariant to replicate.
### 2.2 Create a group (`CreateGroupDialog.tsx` + `useGroupModeration.ts`)
A "create channel" is a **two-event sequence**, then a bookkeeping write:
```ts
// 1. kind 9007 create-group — just the h tag.
await createGroup({ groupId });
// → publishEvent({ kind: 9007, content: "", tags: [["h", groupId]], relay })
// 2. kind 9002 edit-metadata — name/about/visibility.
await editMetadata.mutateAsync({ name, about, isPrivate, isClosed });
// → tags: [["h", groupId], ["name", name], ["about", about],
// [isPrivate ? "private" : "public"], [isClosed ? "closed" : "open"]]
// 3. remember it in the user's kind 10009 list (best-effort).
updateList({ type: "add-group", ref: { id: groupId, relay: relayUrl } });
```
The group id is client-minted (`crypto.getRandomValues(8)` → hex). The creator
becomes admin automatically (the relay assigns the group-creator role). Then the
UI navigates to `/s/<relayParam>/<groupId>`.
> Server note: Armada's own relay (`server/group.go`) **restricts kind 9007 to
> configured admin pubkeys** — on that deployment only operators create channels.
> That's a relay-policy choice, not a NIP-29 requirement.
### 2.3 Send a chat message (`ChatComposer.tsx`)
A message is **kind 9** with `["h", groupId]` plus standard Nostr tags. The tag
builder (`buildMessageTags`) assembles:
- `["h", groupId]` — always first, required.
- `["t", hashtag]` — extracted hashtags.
- `["p", pubkey]` — NIP-27 mentions decoded from `nostr:npub…` in content.
- NIP-10 marked reply tags when replying:
`["e", rootId, relay, "root", rootAuthor]` + `["e", replyId, relay, "reply", replyAuthor]`.
- `["q", …]` — NIP-18 quotes for embedded nevent/naddr.
- NIP-30 custom emoji tags, NIP-92 `imeta` tags for uploads.
- **No `previous` tag** (see §1.4).
Sending is **optimistic**: the event is signed locally, inserted into the
timeline cache with a `pending` status, then sent. Because the id is computed at
sign time, the relay's echo of the same event dedupes automatically against the
optimistic copy (id match) and flips it to confirmed.
### 2.4 Edit metadata / moderation (`useGroupModeration.ts`)
All moderation is one small hook returning mutations, each a single pinned
publish. Exact tag shapes:
| Action | kind | tags |
|--------|------|------|
| putUser | 9000 | `[["h",g], ["p", pubkey, ...roles]]` |
| removeUser | 9001 | `[["h",g], ["p", pubkey]]` (content = reason) |
| deleteEvent | 9005 | `[["h",g], ["e", eventId]]` |
| editMetadata | 9002 | `[["h",g], ...metadataTags(patch)]` |
| deleteGroup | 9008 | `[["h",g]]` |
| createInvite | 9009 | `[["h",g], ["code", code]]` |
`metadataTags` emits antonym tags to clear flags (`public`/`open`) but only
*asserts* `restricted`/`hidden` (no documented antonyms). After a successful
moderation write, the relevant TanStack query keys are invalidated so the
39xxx-derived views refetch.
### 2.5 Invites (`InvitePeopleDialog.tsx`)
Opening the invite dialog **immediately mints an invite and builds a link** — the
"silly-easy part." Two-tier logic:
1. **Prefer a relay-level claim (NIP-43 / zooid).** `useRelayClaim` queries the
relay for a `kind 28935` invite it issues to authed members; if present, its
`claim` tag value is the invite code.
2. **Fall back to a per-group NIP-29 code (kind 9009).** If the relay issues no
claim, mint a random 6-byte code and publish `createInvite({ code })`.
The shareable URL is `…/s/<relayParam>/<groupId>?code=<code>`. Anyone opening it
hits `GroupPage`, which auto-joins using the `code` query param.
### 2.6 Join / leave / membership (`useGroupMembership.ts`, `useRelayMembership.ts`)
**Join (kind 9021)** is a two-step handshake, in this order:
```ts
// 1. Best-effort RELAY-level join first (zooid/Coracle relays gate ALL writes
// behind relay membership, rejecting non-members before group join is even
// considered). Ephemeral kind 28934 carrying the invite as a `claim` tag.
// NEVER throws — no-ops on relays that don't implement it (e.g. relay29).
await joinRelay({ relayUrl, claim: code });
// 2. The real NIP-29 group join. Same invite carried as a `code` tag.
await publishEvent({ kind: 9021, content: reason, tags: [["h",g], ["code",code]], relay });
// "already a member" rejection is treated as success.
```
**Leave (kind 9022):** `publishEvent({ kind: 9022, tags: [["h",g]], relay })`.
The relay auto-issues the corresponding 9001 remove-user.
**Membership state** is derived per NIP-29 by querying the *latest* of
`{kinds:[9000,9001], "#h":[g], "#p":[me]}` on the host relay — 9000 latest ⇒
member, 9001 latest ⇒ not. Polls every 30s; 15s stale time.
**Relay membership (NIP-43)** is its own layer, cleanly separated: `useRelayClaim`
(fetch a 28935 claim), `useJoinRelay` (publish 28934 with the claim),
`useLeaveRelay` (28936). All best-effort and non-throwing — the group join is the
source of truth for the actual outcome.
---
## 3. How Armada displays information
### 3.1 The three-pane Discord layout
```
┌────┬──────────────┬───────────────────────────┐
│rail│ channel list │ message timeline │
│ │ (this relay) │ + composer │
│ 🟦 │ # general │ ...kind 9 messages... │
│ 🟩 │ # random │ │
│ ➕ │ # dev │ [type a message] │
└────┴──────────────┴───────────────────────────┘
servers channels chat
=relays =NIP-29 groups =kind 9
```
- **`ServerRail.tsx`** — the far-left vertical rail. Each icon is a **relay**
(`{ kind: "server", url }`), fed by the pinned `PLATFORM_RELAYS` +
`config.addedRelays`. Supports Discord-style drag-to-reorder and **folders**.
(It also unifies in Concord E2EE communities, which are *not* NIP-29 — ignore
those.) Server order is synced to the kind-10009 `r` tags.
- **`ChannelSidebar`** — for the selected relay, lists its groups via
`useRelayGroups(relayUrl)`.
- **`GroupChat.tsx`** — the timeline + composer for the selected group.
Selecting a relay first, then a room, is *exactly* the "select the relay first
and then pick the rooms in each relay" UX in the ask. Folders are Armada's answer
to "organization to group rooms on" — but note they group **relays**, not rooms;
rooms are grouped implicitly by their host relay.
### 3.2 Fetching a relay's channels (`useRelayGroups.ts`)
Lists a relay's groups by querying `{kinds:[39000]}` on that relay. Key
subtleties:
- **Trust:** kind 39000 must be signed by the relay's own key. Armada reads the
relay's `self`/`pubkey` from its NIP-11 doc and adds `authors:[relaySelf]` so
**forged metadata from other publishers is never trusted**. Until NIP-11
resolves it races a direct fetch (2s) then refetches once when the key lands.
- **Hidden groups:** relays hide closed/private groups from open listings, so the
ids the user *remembers* (their kind-10009 `group` tags for this relay) are
queried explicitly by `#d` and merged in.
- **Provenance scoping:** several relays can share a signing key (zooid ships a
shared identity), so author-scoping alone bleeds channels across relays. Armada
records *which relay actually served* each cached event ("provenance") and
scopes the IndexedDB cache read by it. This is a real, painful edge case worth
remembering.
- **Cache-as-floor:** cached events are merged *under* live ones so a sparse/flaky
relay read can only add, never clear the list. Long stale time (1h), no polling
— channel metadata is the most stable thing in the app.
### 3.3 Fetching a group's roster (`useGroup.ts`)
One query pulls the newest of `{kinds:[39000,39001,39002,39003], "#d":[groupId]}`
(optionally author-scoped to the relay key) and composes
`{ group, admins, members, roles }` (newest event per kind wins). **Local-first:**
the plaintext 39xxx events are mirrored to IndexedDB, so a previously-opened
group renders its roster instantly, with a background relay refresh.
### 3.4 The message timeline (`useGroupMessages.ts`)
The most sophisticated hook. For `(relayUrl, groupId)`:
- **Timeline kinds:** kind 9 + kind 1068 (polls); live sub also watches kind 5
(deletions).
- **Local-first + snapshot-first paint:** seeds from a synchronous localStorage
"last screenful" snapshot → IndexedDB store → background relay page, merged
append-only so nothing already shown is dropped.
- **Scroll-up pagination:** `loadOlder()` walks an `until` cursor with a
gap-guard (Ditto's pattern) so a stale straggler doesn't leap the cursor past
real history.
- **Live subscription:** one `req` with a 5-minute `since` lookback (so a message
that arrived via push before the group was opened still replays); dedupes by id;
processes kind-5 deletions by dropping referenced ids.
- **Optimistic send status** map (`pending`/`failed`) reconciled by relay echo.
- **Resilience:** 60s backstop poll + refetch-on-focus/reconnect to heal
half-dead mobile sockets where the live socket silently died.
### 3.5 Group discovery / "home" (`useUserGroupList.ts`, kind 10009)
The **cross-device source of truth** for a user's memberships is a single kind
10009 event (NIP-51 "simple groups"):
- `group` tags `["group", id, relay]` — joined groups (with host relay).
- `r` tags `["r", relayUrl]` — servers/relays in use.
- Both stored as **NIP-44 private items** (encrypted to self in `.content`);
read-modify-write via `useUpdateUserGroupList`. Armada persists the *decrypted*
list to disk ("folded cache") so boot doesn't pay a signer round-trip, and
refuses to write if it couldn't decrypt the prior list (avoids wiping it).
There is also a `useGroupSearch` (NIP-50 `search` scoped by `#h`, merged with the
local timeline cache) for in-group message search.
### 3.6 What else rides on the `h` tag
Armada extends the group with several kinds, all scoped by `["h", groupId]` so
the relay routes/authorizes them: **pins** (kind 39041, an Armada extension,
addressable `d`=groupId, admin-only), **calendar events** (NIP-52
31922/31923/31925), **reactions** (kind 7), **threaded replies** (kind 1111
NIP-22 comments), and **webxdc mini-apps** (9450/24450). All follow the same
pattern: `["h", groupId]` + pin to host relay. Useful precedent that "anything
can be a group event if it carries `h` and the relay accepts the kind."
---
## 4. Design constraints & gotchas (the expensive lessons)
1. **Relay-scoping is absolute.** Group events go only to the host relay, and
group queries hit only the host relay. Amethyst's relay client fans out to
many relays by default — NIP-29 needs a *single-relay* send/subscribe path.
2. **39xxx is relay-signed; trust it by the relay's own key.** Filter directory
queries by `authors:[relayNip11Pubkey]`. Never trust group metadata otherwise.
3. **Shared relay identities bleed groups.** If you cache 39000 by author only,
two relays sharing a key cross-contaminate. Track per-relay provenance.
4. **`previous` tags cause silent drops** unless you can guarantee refs are in the
relay's last-50 ring. Omit them for single-host groups.
5. **Two membership layers.** NIP-29 group membership (9000/9001) is distinct from
relay-level membership (NIP-43, 28934/28935). Community relays (zooid) gate on
the latter *first*. Do the relay handshake best-effort, treat the group join as
authoritative.
6. **Cache-as-floor everywhere.** A flaky relay returning nothing must never blank
a channel list or roster. Merge cache under live, never overwrite.
7. **Optimistic UI needs local signing.** Sign locally, insert with pending
status, dedupe on relay echo by id.
---
## 5. Amethyst integration plan
### 5.1 What already exists (survey)
**Protocol (Quartz) — largely present.** `quartz/.../nip29RelayGroups/` already
has:
- `metadata/` — `GroupMetadataEvent` (kind **39000**), `GroupAdminsEvent`
(39001), `GroupMembersEvent` (39002), `SupportedRolesEvent` (39003).
- `moderation/` — `CreateGroupEvent` (9007), `EditMetadataEvent` (9002),
`PutUserEvent` (9000), `RemoveUserEvent` (9001), `DeleteEventEvent` (9005),
`DeleteGroupEvent` (9008), `CreateInviteEvent` (9009), plus tag helpers.
- `request/` — `JoinRequestEvent` (9021), `LeaveRequestEvent` (9022).
- `tags/` — `GroupIdTag` (the `h` tag), `CodeTag`, `GroupAdminTag`, `RoleTag`,
and even a `PreviousTag`.
**Relay membership (Quartz) — present.** `quartz/.../nip43RelayMembers/` has the
full NIP-43 handshake Armada calls "relay membership": `RelayJoinRequestEvent`,
`RelayInviteRequestEvent`, `RelayAddMemberEvent`, `RelayLeaveRequestEvent`,
`RelayMembershipListEvent`, `ClaimTag`, `MemberTag`. There's even an
`amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`.
**The UX analog (commons + amethyst) — present and close.** The `ephemChat`
feature (NIP-C7, kind **23333**) is a *relay-scoped* chat room:
- `quartz/.../experimental/ephemChat/chat/EphemeralChatEvent.kt` — kind 23333,
with `RoomId(room, relayUrl)` — **the same `(relay, id)` shape as a NIP-29
address.**
- `commons/.../model/emphChat/EphemeralChatChannel.kt` — `EphemeralChatChannel`
with `relays() = setOf(roomId.relayUrl)`, wired into `LocalCache`, `Account`,
`Note`, plus an `EphemeralChatListState`.
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/` — a full UI:
`EphemeralChatScreen`, `LoadEphemeralChatChannel`, `EphemeralChatChannelHeader`,
`JoinChatButton`/`LeaveChatButton`, a `NewEphemeralChatScreen`, and datasource
sub-assemblers (`FilterMessagesToEphemeralChat`, etc.).
So the Messages/Chats screen already hosts **four** conversation types:
NIP-04/17 DMs, NIP-28 public channels, and NIP-C7 ephemeral chats — all under
`chats/`. **NIP-29 groups become the fourth sibling.**
### 5.2 The main protocol gap
There is **no kind-9 group chat message event** in `nip29RelayGroups/`
(`CreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9).
The 9xxx moderation, 39xxx metadata, and 9021/9022 request events exist, but the
actual message carrier does not. This is the first thing to build:
- `nip29RelayGroups/chat/GroupChatEvent.kt` — kind 9, `["h", groupId]` required,
NIP-10 reply markers, NIP-27 mentions, NIP-92 imeta, NIP-30 emoji — mirror
`ChannelMessageEvent` (NIP-28) which already does all of this, but swap the
channel `e`-root tag for the `h` group tag. Optionally kind 11 (thread) and
reuse NIP-22 `CommentEvent` for replies.
Also verify the existing 39000–39003 parsers expose the flag tags
(`private`/`closed`/`restricted`/`hidden`/`livekit`/`supported_kinds`) and roles
per §1.3; extend if not.
### 5.3 Recommended architecture mapping
| Armada (React) | Amethyst target | Notes |
|----------------|-----------------|-------|
| `lib/nip29.ts` | `quartz/.../nip29RelayGroups/` | mostly exists; add kind-9 `GroupChatEvent` + any missing parsers |
| `useNostrPublish({relay})` | a single-relay send in `commons/.../relayClient/` | **critical new capability**: publish/subscribe pinned to one relay |
| `useRelayGroups` | a `RelayGroupsState` / filter assembler | query 39000 on one relay, author-scoped to its NIP-11 key |
| `useGroup` | `GroupChannel` model + roster state | compose newest 39000–39003; mirror `EphemeralChatChannel` |
| `useGroupMessages` | a NIP-29 `FeedFilter` + `FeedContentState` | reuse `chats/publicChannels/datasource` sub-assembler pattern |
| `useUserGroupList` (10009) | an `Account` state object (like `ephemeralChatListState`) | NIP-44 private items; StateFlow of joined groups + servers |
| `useGroupMembership` | membership derivation from 9000/9001 | latest-wins per NIP-29 |
| `useRelayMembership` (NIP-43) | already in `nip43RelayMembers` + `RelayMembersScreen` | best-effort handshake before join |
| `ServerRail` + `ChannelSidebar` | Android: a relay picker → channel list inside the Chats tab | see §5.4 |
| `GroupChat` + `ChatComposer` | reuse the ephemChat/NIP-28 chat screen + composer | swap the datasource + send to kind 9 + `h` |
**Placement per CLAUDE.md:** protocol → `quartz/`; the group model, list state,
membership derivation, ViewModels/filters → `commons/` (so Desktop + CLI share);
screen composables + navigation → `amethyst/` (bottom-nav) and `desktopApp/`
(sidebar). The ephemChat feature is the template to copy for all three layers.
### 5.4 Recommended Amethyst UX
The ask floats three options; the study points to a clear answer:
- **Not** a flat list of rooms mixed into the DM inbox. NIP-29 rooms are
relay-scoped and there can be many per relay — mixing them into the DM room
list loses the relay grouping and doesn't scale.
- **Yes** to "select the relay first, then pick rooms in that relay." This is
Armada's model and it matches the protocol's addressing exactly. On Android
(bottom-nav, no room for a permanent Discord rail), the natural shape is:
- A **"Groups"/"Servers" entry inside the existing Chats tab** (alongside DMs,
Public Chats, Ephemeral Chats).
- Level 1: **your relays** (from kind-10009 `r` tags) — an "add relay" affordance
and each row shows unread rollup.
- Level 2: tap a relay → **its channels** (from `useRelayGroups`-equivalent),
with a create-channel action (subject to relay policy).
- Level 3: tap a channel → the **existing chat screen**, re-pointed at a NIP-29
kind-9 datasource.
- "Organization to group rooms on" = the **relay is the grouping**; add
Discord-style relay folders later if desired (Armada's `railLayout`).
- Desktop can render the true three-pane rail (it already uses a sidebar shell).
Deep-linking: adopt Armada's invite-link idea via Nostr-native addressing —
`naddr` to the kind-39000 (kind + relay-key author + `d`=groupId + relay hint),
plus an optional invite `code`. Amethyst already resolves `naddr`; a group `naddr`
should route into the channel and, if a code is present, fire a 9021 join.
### 5.5 Suggested build order
1. **Quartz:** add `GroupChatEvent` (kind 9) + builders; confirm 39000 flag/role
parsing. Unit-test against Armada-produced events (spin up `./start.sh` or use
`chat.soapbox.pub`).
2. **Relay client:** add a single-relay pinned publish + subscription path
(the `relay: relayUrl` equivalent). This unblocks everything else.
3. **commons:** `GroupChannel` model + `UserGroupListState` on `Account` (kind
10009, mirror `EphemeralChatListState`) + membership derivation.
4. **commons:** NIP-29 message `FeedFilter`/`FeedContentState` (copy the
ephemChat/NIP-28 sub-assembler; timeline kinds 9 + 1068 + 5).
5. **amethyst:** relay-picker → channel-list screens in the Chats tab; re-point
the chat screen/composer at the kind-9 datasource; join/leave/create/invite
dialogs (reuse `nip43RelayMembers` for the relay handshake).
6. **Later:** roles/moderation UI, pins, reactions, threads, calendar, LiveKit AV.
### 5.6 Explicitly out of scope
Armada's `concord-v1`/`concord-v2` directories are a **separate** end-to-end
encrypted community protocol (sealed envelopes, rekeying) — *not* NIP-29. They
share the chat *components* via a `ChatTransport` abstraction but nothing else.
Ignore them for NIP-29. (The `ChatTransport` pattern — a presentational chat UI
fed by a capability interface — is itself a nice idea worth borrowing so DMs,
NIP-28, ephemeral, and NIP-29 all render through one component.)
---
## 6. Key file references
**Armada (studied):**
- `client/src/lib/nip29.ts` — constants, parsers, tag builders
- `client/src/hooks/useNostrPublish.ts` — the `relay`-pinned publish chokepoint
- `client/src/hooks/useGroupModeration.ts` — 9000/9001/9002/9005/9007/9008/9009
- `client/src/hooks/useGroupMembership.ts` + `useRelayMembership.ts` — 9021/9022 + NIP-43
- `client/src/hooks/useRelayGroups.ts` / `useGroup.ts` / `useGroupMessages.ts` — display
- `client/src/hooks/useUserGroupList.ts` — kind 10009 home base
- `client/src/components/layout/ServerRail.tsx` — Discord rail (relay = server)
- `client/src/components/chat/ChatComposer.tsx` — kind-9 send + the `previous`-tag rationale
- `client/src/components/dialogs/{CreateGroup,InvitePeople}Dialog.tsx`
- `server/group.go`, `server/invites.go`, `server/unmanaged.go` — relay29 policy
**Amethyst (integration surface):**
- `quartz/.../nip29RelayGroups/**` — existing protocol events (add kind-9 chat)
- `quartz/.../nip43RelayMembers/**` — relay membership handshake
- `quartz/.../experimental/ephemChat/**` + `commons/.../model/emphChat/**` — the relay-scoped chat analog
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/**` — UI template
- `amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`
- `quartz/.../nip28PublicChat/message/ChannelMessageEvent.kt` — the kind-9 builder to mirror
@@ -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.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
@@ -59,6 +60,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
@@ -106,6 +108,7 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST = "defaultRelayGroupsDiscoveryFollowList"
const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList"
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
@@ -150,6 +153,7 @@ private object PrefKeys {
const val LATEST_HASHTAG_LIST = "latestHashtagList"
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val CALLS_ENABLED = "calls_enabled"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -157,6 +161,7 @@ private object PrefKeys {
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
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 SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
@@ -426,6 +431,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultRelayGroupsDiscoveryFollowList.value))
putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value))
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
@@ -498,6 +504,7 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList)
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
@@ -509,6 +516,7 @@ object LocalPreferences {
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
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)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
// Any account that reaches a save has its notification filter in its
@@ -629,6 +637,7 @@ object LocalPreferences {
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.IF_IN_MY_LIST
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
@@ -663,6 +672,7 @@ object LocalPreferences {
val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null)
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
@@ -722,6 +732,7 @@ object LocalPreferences {
val latestHashtagList = async { parseEventOrNull<HashtagListEvent>(latestHashtagListStr) }
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestCashuWallet =
@@ -773,6 +784,7 @@ object LocalPreferences {
val latestHashtagListResolved = latestHashtagList.await()
val latestGeohashListResolved = latestGeohashList.await()
val latestEphemeralListResolved = latestEphemeralList.await()
val latestRelayGroupListResolved = latestRelayGroupList.await()
val latestTrustProviderListResolved = latestTrustProviderList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestCashuWalletResolved = latestCashuWallet.await()
@@ -796,6 +808,7 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultRelayGroupsDiscoveryFollowList = MutableStateFlow(followListPrefs.relayGroupsDiscovery),
defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets),
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
@@ -833,6 +846,7 @@ object LocalPreferences {
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -852,6 +866,7 @@ object LocalPreferences {
backupHashtagList = latestHashtagListResolved,
backupGeohashList = latestGeohashListResolved,
backupEphemeralChatList = latestEphemeralListResolved,
backupRelayGroupList = latestRelayGroupListResolved,
backupTrustProviderList = latestTrustProviderListResolved,
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
@@ -890,6 +905,7 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val relayGroupsDiscovery: TopFilter,
val napplets: TopFilter,
val nsites: TopFilter,
val workouts: TopFilter,
@@ -946,6 +962,7 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine),
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -34,6 +34,9 @@ import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
@@ -199,6 +202,16 @@ 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.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
@@ -250,6 +263,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
@@ -365,6 +379,9 @@ class Account(
val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer)
val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings)
val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer)
val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings)
val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer)
val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings)
@@ -539,6 +556,9 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveRelayGroupsDiscoveryFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultRelayGroupsDiscoveryFollowList)
val liveRelayGroupsDiscoveryFollowListsPerRelay = OutboxLoaderState(liveRelayGroupsDiscoveryFollowLists, cache, scope).flow
val liveNappletsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNappletsFollowList)
val liveNappletsFollowListsPerRelay = OutboxLoaderState(liveNappletsFollowLists, cache, scope).flow
@@ -1445,6 +1465,154 @@ class Account(
suspend fun unfollow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.unfollow(channel))
suspend fun follow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.follow(channel))
suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel))
// ── 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
// list is our own cross-device bookkeeping of what we joined.
/** Send a kind 9021 join request to the group's host relay and remember it. */
suspend fun joinRelayGroup(
channel: RelayGroupChannel,
code: String? = null,
) {
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
follow(channel)
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
unfollow(channel)
}
/**
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
* the new group's id.
*/
suspend fun createRelayGroup(
relay: NormalizedRelayUrl,
groupId: String,
name: String,
about: String? = null,
picture: String? = null,
isPrivate: Boolean = false,
isClosed: Boolean = false,
isHidden: Boolean = false,
isRestricted: Boolean = false,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
): GroupId {
signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) }
val edit =
EditMetadataEvent.build(
groupId,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
)
signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
val id = GroupId(groupId, relay)
follow(LocalCache.getOrCreateRelayGroupChannel(id))
return id
}
/**
* The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are
* presence-only — public/open/visible/unrestricted are simply the ABSENCE of their
* restrictive counterpart — so only the enabled restrictive flags are added.
*/
private fun relayGroupStatus(
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
): Set<GroupMetadataEvent.GroupStatus> =
buildSet {
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN)
if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
}
/** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */
suspend fun postRelayGroupThread(
channel: RelayGroupChannel,
title: String,
body: String,
) {
val template = ThreadEvent.build(body, title) { hTag(channel.groupId.id) }
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Mint a kind 9009 invite code for the group (admin/moderator only). */
suspend fun createRelayGroupInvite(
channel: RelayGroupChannel,
code: String,
) {
val template = CreateInviteEvent.build(channel.groupId.id, code)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
suspend fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
) {
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
* event (moderator only). Pass an empty [roles] list for a plain member.
*/
suspend fun putRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
roles: List<String>,
) {
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles))
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */
suspend fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
about: String?,
picture: String?,
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
) {
val template =
EditMetadataEvent.build(
channel.groupId.id,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community))
suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community))
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
@@ -58,6 +60,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -212,6 +215,7 @@ class AccountSettings(
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
@@ -242,6 +246,7 @@ class AccountSettings(
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupRelayGroupList: SimpleGroupListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
@@ -269,7 +274,9 @@ class AccountSettings(
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
) : EphemeralChatRepository,
RelayGroupRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
@@ -284,6 +291,13 @@ class AccountSettings(
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun updateRelayGroupViewMode(mode: RelayGroupViewMode) {
if (relayGroupViewMode.value != mode) {
relayGroupViewMode.tryEmit(mode)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -645,6 +659,17 @@ class AccountSettings(
}
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: FeedDefinition) {
changeDefaultRelayGroupsDiscoveryFollowList(name.code)
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: TopFilter) {
if (defaultRelayGroupsDiscoveryFollowList.value != name) {
defaultRelayGroupsDiscoveryFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
changeDefaultNappletsFollowList(name.code)
}
@@ -1213,6 +1238,20 @@ class AccountSettings(
}
}
override fun relayGroupList() = backupRelayGroupList
override fun updateRelayGroupListTo(newRelayGroupList: SimpleGroupListEvent?) {
// Joined groups can live in the NIP-44 private items (encrypted content),
// so an empty `tags` is NOT an empty list — guard only on null.
if (newRelayGroupList == null) return
// Events might be different objects, we have to compare their ids.
if (backupRelayGroupList?.id != newRelayGroupList.id) {
backupRelayGroupList = newRelayGroupList
saveAccountSettings()
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
@@ -152,6 +153,22 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -195,6 +212,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -253,6 +271,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
@@ -334,6 +353,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val publicChatChannels = LargeCache<HexKey, PublicChatChannel>()
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
val relayGroupChannels = LargeCache<GroupId, RelayGroupChannel>()
val paymentTracker = NwcPaymentTracker()
@@ -607,6 +627,11 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key)
fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key)
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
@@ -695,6 +720,8 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) }
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? {
if (isValidHex(key)) {
return getOrCreatePublicChatChannel(key)
@@ -1722,6 +1749,118 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable
* note and used to populate the [RelayGroupChannel]'s name/picture/about/
* flags. The group is keyed by (host relay + group id): unlike NIP-C7, a
* NIP-29 event does not carry its host relay in a tag — the host is the relay
* that served it — so the channel can only be associated when we know the
* serving relay (provenance). With no relay we still store the metadata.
*/
fun consume(
event: GroupMetadataEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) {
val note = getOrCreateAddressableNote(event.address())
val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay))
(note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) }
}
return new
}
/** NIP-29 relay-signed member list (kind 39002) → the group's roster. */
fun consume(
event: GroupMembersEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) }
}
return new
}
/** NIP-29 relay-signed admin list (kind 39001) → the group's roster. */
fun consume(
event: GroupAdminsEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) }
}
return new
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
* content kinds and scopes them with `h`, so the note is consumed normally
* and then, when it belongs to a group and we know the serving relay, added
* to that group's channel timeline.
*/
private fun attachToRelayGroupIfScoped(
event: Event,
relay: NormalizedRelayUrl?,
) {
val groupId = event.groupId() ?: return
val note = getOrCreateNote(event.id)
// Only attach a note we've actually loaded — never a placeholder for an
// unverified/not-yet-seen event. This is checked here (not via the "was
// newly consumed" flag) so the host relay's echo of an event we already
// stored from our own send still lands in the channel.
if (note.event == null) return
if (relay != null) {
// Normal arrival: the group only exists on its host relay and the
// filters are host-pinned, so the serving relay is the group's key.
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addNote(note, relay)
} else {
// Our own optimistic send has no provenance relay, so we can't build the (groupId,
// relay) key. Attach only when a SINGLE open channel has this group id (the room being
// composed in). When the id is ambiguous across relays — e.g. the relay-wide "_" group
// joined on several relays — skip: attaching to all of them bleeds the message into
// rooms it wasn't sent to. The host relay's echo (relay != null) lands it on the right key.
relayGroupChannels
.filter { key, _ -> key.id == groupId }
.singleOrNull()
?.addNote(note, null)
}
}
/**
* Same routing as [attachToRelayGroupIfScoped] but for kind-11 threads, which
* are kept in a separate collection from the chat timeline so the two content
* types don't mix in one feed.
*/
private fun attachThreadToRelayGroupIfScoped(
event: Event,
relay: NormalizedRelayUrl?,
) {
val groupId = event.groupId() ?: return
val note = getOrCreateNote(event.id)
if (note.event == null) return
if (relay != null) {
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addThread(note)
} else {
// See attachToRelayGroupIfScoped: only attach when the group id is unambiguous.
relayGroupChannels
.filter { key, _ -> key.id == groupId }
.singleOrNull()
?.addThread(note)
}
}
fun consume(
event: LiveActivitiesChatMessageEvent,
relay: NormalizedRelayUrl?,
@@ -2655,6 +2794,10 @@ object LocalCache : ILocalCache, ICacheProvider {
publicChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
relayGroupChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
}
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
@@ -2705,6 +2848,10 @@ object LocalCache : ILocalCache, ICacheProvider {
pruneOldMessagesChannel(channel)
}
relayGroupChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
chatroomList.forEach { userHex, room ->
// History floors are pinned per scope on first advance; null means that window never paged
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
@@ -3542,6 +3689,75 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups +
// servers. Replaceable like its sibling lists; RelayGroupListState reads it from the
// addressable cache, so it must be stored (it was silently dropped before).
is SimpleGroupListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is GroupMetadataEvent -> {
consume(event, relay, wasVerified)
}
is GroupMembersEvent -> {
consume(event, relay, wasVerified)
}
is GroupAdminsEvent -> {
consume(event, relay, wasVerified)
}
// Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003
// roles, 39004 AV participants) are durable group state alongside 39000/39001/39002,
// so they're stored replaceably. The 9xxx moderation actions and join/leave requests
// are regular one-shot events the relay is authoritative for (it applies them and
// republishes the 39000/39001/39002); we store them so they're queryable and don't
// fall through to the "Not Supported" warning, but we don't act on them client-side.
is SupportedRolesEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is GroupParticipantsEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is PutUserEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is RemoveUserEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is EditMetadataEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is DeleteEventEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is DeleteGroupEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is CreateGroupEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is CreateInviteEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is JoinRequestEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is LeaveRequestEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is ExternalIdentitiesEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
@@ -3896,11 +4112,25 @@ object LocalCache : ILocalCache, ICacheProvider {
}
is ChatEvent -> {
consumeRegularEvent(event, relay, wasVerified)
consumeRegularEvent(event, relay, wasVerified).also {
// Attach on every arrival, not just the newly-consumed one:
// our own send is consumed first with a null relay, so the
// host relay's later echo (new == false) is what carries the
// provenance needed to key the channel. attach is idempotent.
attachToRelayGroupIfScoped(event, relay)
}
}
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified)
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)
}
}
is ThreadEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachThreadToRelayGroupIfScoped(event, relay)
}
}
is PollResponseEvent -> {
@@ -21,16 +21,48 @@
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
fun loadRelayInfo(relay: NormalizedRelayUrl): State<Nip11RelayInformation> = loadRelayInfo(relay, Amethyst.instance.nip11Cache)
/**
* Eagerly warms the NIP-11 cache for a whole set of [relays] **in parallel** (each fetch on its own
* coroutine), so callers that later read the cache — e.g. the NIP-29 relay-signed group check — get
* hits instead of cold fetches. Warming a set serially would sum every relay's latency and let one
* slow/unreachable relay stall the rest until its socket timeout; fanning out bounds the wait to the
* slowest single fetch. [onEachLoaded] fires (on an IO thread) as each relay resolves, so a screen
* can re-evaluate incrementally as docs arrive. The cache dedups, so re-warming is cheap.
*/
@Composable
fun WarmNip11(
relays: Collection<NormalizedRelayUrl>,
onEachLoaded: () -> Unit = {},
) {
val cache = Amethyst.instance.nip11Cache
LaunchedEffect(relays) {
coroutineScope {
relays.forEach { relay ->
launch {
cache.loadRelayInfo(
relay = relay,
onInfo = { onEachLoaded() },
onError = { _, _, _ -> onEachLoaded() },
)
}
}
}
}
}
@Composable
fun loadRelayInfo(
relay: NormalizedRelayUrl,
@@ -87,11 +87,16 @@ class Nip11CachedRetriever(
}
is RetrieveResult.Loading -> {
if (doc.isValid()) {
// just wait.
} else {
retrieve(relay, onInfo, onError)
}
// A `Loading` marker means SOME caller started a fetch — but the coroutine that
// owns it may already be gone (e.g. the composable that launched the warm-up left
// composition and its scope was cancelled mid-fetch), which would leave this marker
// stuck and valid for an hour. The old "just wait" here dropped this caller's
// callback entirely, so a screen that navigated in on top of an aborted load would
// never receive the doc and would render as if the relay had no NIP-11 (no `self`,
// no supported_nips) — hiding relay-signed NIP-29 groups until the marker expired.
// Re-fetch instead: the fetch is cheap, dedups at the HTTP layer, and guarantees
// this caller is notified.
retrieve(relay, onInfo, onError)
}
is RetrieveResult.Error -> {
@@ -0,0 +1,71 @@
/*
* 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.model.nip11RelayInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
/**
* Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g.
* "29"). Reads only the in-memory cache — it never blocks on a network fetch — so it returns false
* for a relay whose NIP-11 hasn't been loaded yet. Callers that need the answer to become true must
* warm the document first (e.g. `loadRelayInfo`), then re-evaluate once it resolves.
*/
fun relayAdvertisesNip(
relay: NormalizedRelayUrl,
nip: String,
): Boolean =
Amethyst.instance.nip11Cache
.getFromCache(relay)
.supported_nips
?.any { it == nip } == true
/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */
fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29")
/**
* Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29:
* "these are addressable events signed by the relay keypair directly … as stated by the NIP-11
* `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else".
*
* So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self`
* key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29
* relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we
* fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self`
* still works. A relay with neither fails. Reads only the cached NIP-11 doc ([relayInfo]); callers
* driving a live surface should warm it first and re-evaluate as it resolves.
*/
fun isRelaySignedRelayGroup(
channel: RelayGroupChannel,
relayInfo: Nip11RelayInformation,
): Boolean {
val self = relayInfo.self
return if (self != null) {
channel.event?.pubKey == self
} else {
relayInfo.supported_nips?.any { it == "29" } == true
}
}
/** [isRelaySignedRelayGroup] reading the host relay's cached NIP-11 doc (for non-Compose callers). */
fun isRelaySignedRelayGroup(channel: RelayGroupChannel): Boolean = isRelaySignedRelayGroup(channel, Amethyst.instance.nip11Cache.getFromCache(channel.groupId.relayUrl))
@@ -37,6 +37,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.P
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.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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelayFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
@@ -113,6 +118,16 @@ class RelaySubscriptionsCoordinator(
// active depending on the screen.
val channel = ChannelFilterAssembler(client)
// NIP-29 relay-group REQ assemblers, each named for WHEN it runs. There is deliberately no
// "group chat" assembler: a group's kind-9 chat timeline is served by the shared [channel]
// assembler above (same as NIP-28 public chats), so only the group-specific surfaces get their
// own here.
val relayGroupsOnRelay = RelayGroupsOnRelayFilterAssembler(client) // browsing one relay's channel list
val relayGroupMyJoinedGroups = RelayGroupMyJoinedGroupsFilterAssembler(client) // metadata+rosters of groups I've joined
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
val chatroom = ChatroomFilterAssembler(client)
val community = CommunityFilterAssembler(client)
val gitRepository = RepositoryFilterAssembler(client)
@@ -174,6 +189,11 @@ class RelaySubscriptionsCoordinator(
val all =
listOf(
relayGroupsOnRelay,
relayGroupMyJoinedGroups,
relayGroupThreadFeed,
relayGroupWarmup,
relayGroupsDiscovery,
account,
accountForeground,
home,
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
@@ -79,6 +80,10 @@ val AccountInfoAndListsFromKeyKinds2 =
PaymentTargetsEvent.KIND,
RelayFeedsListEvent.KIND,
InterestSetEvent.KIND,
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers.
// 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,
// 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
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
@@ -69,6 +70,8 @@ val BasicAccountInfoKinds2 =
TrustProviderListEvent.KIND,
RelayFeedsListEvent.KIND,
InterestSetEvent.KIND,
// NIP-51 "simple groups" list (kind 10009), matching the sibling NIP-51 lists above.
SimpleGroupListEvent.KIND,
)
fun filterBasicAccountInfoFromKeys(
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -49,19 +50,38 @@ class AccountNotificationsEoseFromInboxRelaysManager(
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
key.account.notificationRelays.flow.value.flatMap {
filterSummaryNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
) +
filterNotificationsToPubkey(
): List<RelayBasedFilter> {
val inbox =
key.account.notificationRelays.flow.value.flatMap {
filterSummaryNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
)
}
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
) +
filterNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
)
}
// NIP-29 group activity (reactions/replies to my messages) lives on the group's host relay,
// not my inbox relays — poll each host relay for those, scoped by `#h` to my joined groups.
val groups =
key.account.relayGroupList.liveRelayGroupList.value
.groupBy({ it.relayUrl }, { it.groupId })
.flatMap { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@flatMap emptyList()
filterGroupNotificationsToPubkey(
relay = relay,
pubkey = user(key).pubkeyHex,
groupIds = groupIds.distinct(),
since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(),
)
}
return inbox + groups
}
val userJobMap = mutableMapOf<User, List<Job>>()
@@ -76,6 +96,13 @@ class AccountNotificationsEoseFromInboxRelaysManager(
invalidateFilters()
}
},
// Re-subscribe when I join/leave a group so its host relay is added to (or dropped
// from) the notification query.
key.account.scope.launch(Dispatchers.IO) {
key.account.relayGroupList.liveRelayGroupList.sample(1000).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
@@ -57,6 +57,25 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/**
* Kinds that notify me about activity on MY messages inside a NIP-29 group — reactions, replies
* (chat + NIP-22 comments), poll responses, reposts, zaps and reports. Scoped by `#h` to my joined
* groups + `#p` to me. These live on the group's host relay, not my inbox relays.
*/
val GroupNotificationKinds =
listOf(
ReactionEvent.KIND,
ChatEvent.KIND,
CommentEvent.KIND,
PollEvent.KIND,
PollResponseEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
LnZapEvent.KIND,
ReportEvent.KIND,
)
val SummaryKinds =
listOf(
@@ -167,6 +186,34 @@ fun filterNotificationsToPubkey(
)
}
/**
* Notifications for activity on my messages inside joined NIP-29 groups, queried on the group's
* HOST relay (where the content lives) scoped by `#h` to those groups and `#p` to me. Without this,
* a reaction/reply to my group message is only fetched when I open the group, never surfacing in
* the notifications feed.
*/
fun filterGroupNotificationsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
groupIds: List<String>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = GroupNotificationKinds,
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
limit = 200,
since = since,
),
),
)
}
fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
@@ -48,6 +48,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -204,6 +206,8 @@ fun uriToRoute(
return connectedAppRoute(uri)
}
relayGroupInviteRoute(uri)?.let { return it }
val nip19 = Nip19Parser.uriToRoute(uri)?.entity
if (nip19 != null) {
LocalCache.consume(nip19)
@@ -230,10 +234,11 @@ fun uriToRoute(
}
is NAddress -> {
routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
relayGroupDirectRoute(nip19)
?: routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> {
@@ -311,3 +316,26 @@ private fun calendarDirectRoute(nip19: NAddress): Route? =
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
else -> null
}
/**
* Direct route for a NIP-29 group `naddr` (kind 39000) shared as a link. The group
* only exists on its host relay, so the naddr's relay hint is mandatory — the group
* id alone can't be resolved. With a hint we open the group chat straight away
* (cold start included); without one there's nowhere to look, so fall through.
*/
private fun relayGroupDirectRoute(nip19: NAddress): Route? {
if (nip19.kind != GroupMetadataEvent.KIND) return null
val relay = nip19.relay.firstOrNull() ?: return null
return Route.RelayGroup(nip19.dTag, relay.url)
}
/**
* Direct route for a NIP-29 group invite link in the de-facto `<relay>'<groupId>[?code=<code>]`
* form shared by Wisp and 0xchat (e.g. `wss://groups.0xchat.com'abc123`). The link carries its
* own host relay, so we can open the group straight away. Returns null for anything that isn't
* this exact shape, so it's safe to try before the nostr-entity parser.
*/
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)
}
@@ -0,0 +1,80 @@
/*
* 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.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import kotlinx.coroutines.launch
/**
* Renders a NIP-29 group invite link (`<relay>'<groupId>[?code=<code>]`, the Wisp/0xchat
* form) inline as a tappable link that opens the group. If the literal can't be parsed
* (it always should, since detection produced it) it falls back to plain text.
*/
@Composable
fun ClickableRelayGroupLink(
linkText: String,
nav: INav,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val invite = remember(linkText) { GroupInviteLink.parse(linkText) }
if (invite == null) {
Text(text = linkText)
return
}
val clickableModifier =
remember(linkText) {
Modifier
.combinedClickable(
onLongClick = {
scope.launch {
clipboardManager.setText(linkText)
}
},
onClick = {
nav.nav(Route.RelayGroup(invite.groupId, invite.relayUrl.url, inviteCode = invite.code))
},
)
}
Text(
text = linkText,
modifier = clickableModifier,
color = MaterialTheme.colorScheme.primary,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
}
@@ -77,6 +77,7 @@ 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.nip19Bech32.toNIP19
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -201,6 +202,21 @@ private fun DisplayAddress(
accountViewModel: AccountViewModel,
nav: INav,
) {
// A NIP-29 group is addressed by its relay-signed kind-39000 metadata. Route
// straight into the group chat rather than the generic addressable-note view.
// The relay is only carried in the naddr's relay hint (the group id is unique
// only per relay), so we need that hint to open it.
val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null
if (groupRelay != null) {
CreateClickableText(
clickablePart = "#${nip19.dTag}",
suffix = additionalChars,
route = Route.RelayGroup(nip19.dTag, groupRelay.url),
nav = nav,
)
return
}
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.aTag())) }
if (noteBase == null) {
@@ -0,0 +1,237 @@
/*
* 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.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.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
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.relayGroup.LoadRelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
/**
* Renders a NIP-29 group invite link (`<relay>'<groupId>[?code=<code>]`) as a full-width
* card. It draws immediately with a stable layout — a robohash avatar seeded from the group
* id, the id as a placeholder name, and the host relay — then fills in the real name, picture
* and member count reactively as the relay-signed metadata arrives, WITHOUT changing the
* card's structure or height. While on screen it also warms the group (live metadata +
* recent messages/threads) so tapping it opens an already-populated screen.
*/
@Composable
fun RelayGroupCard(
linkText: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val invite = remember(linkText) { GroupInviteLink.parse(linkText) }
if (invite == null) {
// Detection produced this, so it should always parse; degrade to a plain link if not.
ClickableRelayGroupLink(linkText, nav)
return
}
val groupId = remember(invite) { invite.toGroupId() }
LoadRelayGroupChannel(groupId, accountViewModel) { channel ->
RelayGroupCardContent(channel, invite.code, accountViewModel, nav)
}
}
@Composable
private fun RelayGroupCardContent(
baseChannel: RelayGroupChannel,
inviteCode: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
// Recompose in place when the relay-signed metadata / roster changes.
val channelState by baseChannel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val memberCount = channel.memberCount()
val relayLabel = channel.groupId.relayUrl.displayUrl()
// Closed (invite-only) is the more actionable signal to a prospective joiner than
// private, so it wins when both are set. Null for a plain open group.
val statusBadge =
when {
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
else -> null
}
// Shown only when the group actually has an about, so description-less groups stay
// compact. It appears (a small one-time grow) once the relay-signed metadata loads.
val description = channel.summary()?.takeIf { it.isNotBlank() }
ElevatedCard(
onClick = {
nav.nav(
Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url, inviteCode = inviteCode),
)
},
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
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)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = channel.toBestDisplayName(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (statusBadge != null) {
StatusPill(statusBadge)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (memberCount > 0) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "·",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = relayLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = stringRes(R.string.relay_group_open),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
}
}
}
/** A small tonal pill flagging group visibility/join policy (e.g. Private / Invite-only). */
@Composable
private fun StatusPill(text: String) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RelayGroupLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
@@ -531,6 +532,8 @@ private fun RenderWordWithoutPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
@@ -569,6 +572,7 @@ private fun RenderWordWithPreview(
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
@@ -67,6 +67,7 @@ object ScrollStateKeys {
const val PICTURES_SCREEN = "PicturesFeed"
const val WORKOUTS_SCREEN = "WorkoutsFeed"
const val GIT_REPOSITORIES_SCREEN = "GitRepositoriesFeed"
const val RELAY_GROUPS_DISCOVERY_SCREEN = "RelayGroupsDiscoveryFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
@@ -105,6 +105,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemC
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupBrowseScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
@@ -212,6 +220,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
@@ -367,6 +376,7 @@ fun BuildNavigation(
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
@@ -463,6 +473,7 @@ fun BuildNavigation(
composableFromEnd<Route.ComposeSettings> { ComposeSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
@@ -555,6 +566,68 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.RelayGroup> {
RelayGroupChatScreen(
id = it.id,
relayUrl = it.relayUrl,
draftId = it.draftId,
replyToId = it.replyTo,
inviteCode = it.inviteCode,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupServer> {
RelayGroupChannelListScreen(
relayUrl = it.relayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupMembers> {
RelayGroupMembersScreen(
id = it.id,
relayUrl = it.relayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupThreads> {
RelayGroupThreadsScreen(
id = it.id,
relayUrl = it.relayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
relayUrl = it.relayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupEdit> {
RelayGroupEditScreen(
id = it.id,
relayUrl = it.relayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupBrowse> {
RelayGroupBrowseScreen(
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
@@ -667,6 +740,8 @@ fun BuildNavigation(
forkId = it.fork,
versionId = it.version,
draftId = it.draft,
groupThreadId = it.groupThreadId,
groupThreadRelayUrl = it.groupThreadRelayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -66,6 +66,7 @@ enum class NavBarItem {
PODCAST_EPISODES,
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
FOLLOW_PACKS,
LIVE_STREAMS,
NESTS,
@@ -318,6 +319,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.AutoMirrored.Chat,
resolveRoute = { Route.PublicChats },
),
NavBarItem.RELAY_GROUPS to
NavBarItemDef(
id = NavBarItem.RELAY_GROUPS,
labelRes = R.string.relay_groups_title,
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.FOLLOW_PACKS to
NavBarItemDef(
id = NavBarItem.FOLLOW_PACKS,
@@ -439,6 +447,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -44,6 +45,9 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -71,8 +75,25 @@ fun routeFor(
return Route.MarmotGroupChat(marmotGroup.nostrGroupId)
}
// NIP-29 relay-group content (kind 9 chat, 11 thread, 1111 comment, polls) should open the
// group chat screen — like every other chat NIP — not the generic thread view it would fall
// through to. A consumed group message is attached to its RelayGroupChannel (which carries the
// host relay), so route via that; fall back to the note's `h` tag + provenance relay otherwise.
val relayGroup = note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }
if (relayGroup != null) {
return routeFor(relayGroup)
}
val noteEvent = note.event ?: return Route.EventRedirect(note.idHex)
if (noteEvent.isGroupScoped()) {
val groupId = noteEvent.groupId()
val hostRelay = note.relays.firstOrNull()
if (groupId != null && hostRelay != null) {
return routeFor(GroupId(groupId, hostRelay))
}
}
return routeFor(noteEvent, loggedIn)
}
@@ -257,6 +278,10 @@ fun routeFor(note: LiveActivitiesChannel): Route = Route.LiveActivityChannel(not
fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl.url)
fun routeFor(note: RelayGroupChannel): Route = Route.RelayGroup(note.groupId.id, note.groupId.relayUrl.url)
fun routeFor(groupId: GroupId): Route = Route.RelayGroup(groupId.id, groupId.relayUrl.url)
fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex)
@@ -404,6 +404,8 @@ sealed class Route {
@Serializable object ReactionsSettings : Route()
@Serializable object MessagesSettings : Route()
@Serializable object AudioVisualizerSettings : Route()
@Serializable object BottomBarSettings : Route()
@@ -637,6 +639,43 @@ sealed class Route {
@Serializable object NewEphemeralChat : Route()
@Serializable data class RelayGroup(
val id: String,
val relayUrl: String,
val draftId: HexKey? = null,
val replyTo: HexKey? = null,
// NIP-29 invite code from a `wss://relay'id?code=…` link — auto-joins a closed
// group when the screen opens. Null for a plain (view-only) group link.
val inviteCode: String? = null,
) : Route()
@Serializable data class RelayGroupServer(
val relayUrl: String,
) : Route()
@Serializable data class RelayGroupMembers(
val id: String,
val relayUrl: String,
) : Route()
@Serializable data class RelayGroupThreads(
val id: String,
val relayUrl: String,
) : Route()
@Serializable data class RelayGroupCreate(
val relayUrl: String,
) : Route()
@Serializable data class RelayGroupEdit(
val id: String,
val relayUrl: String,
) : Route()
@Serializable object RelayGroups : Route()
@Serializable object RelayGroupBrowse : Route()
@Serializable data class ChannelMetadataEdit(
val id: String? = null,
) : Route()
@@ -800,6 +839,10 @@ sealed class Route {
val fork: String? = null,
val version: String? = null,
val draft: String? = null,
// When set, the composer produces a NIP-29 kind-11 group thread scoped to this group id and
// published only to its host relay (see ShortNotePostViewModel.setGroupThread).
val groupThreadId: String? = null,
val groupThreadRelayUrl: String? = null,
) : Route()
@Serializable
@@ -91,6 +91,9 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip22Comments.notify
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
@@ -520,8 +523,31 @@ open class CommentPostViewModel :
val anonymous = wantsAnonymousPost
cancel()
// A reply within a NIP-29 group is group content: pin it to the group's
// host relay (the relay the thread was seen on) instead of the author's
// outbox, so it reaches the group and — for a private/closed group — is
// never leaked to unrelated relays.
val replyGroupId = replyingTo?.event?.takeIf { it.isGroupScoped() }?.groupId()
val groupHostRelays =
replyGroupId?.let { gid ->
// Prefer the group's cached channel host (the pinned host relay), then the relays
// the parent was actually seen on. Never fall through to the author's outbox.
LocalCache.relayGroupChannels
.filter { key, _ -> key.id == gid }
.map { it.groupId.relayUrl }
.distinct()
.takeIf { it.isNotEmpty() }
?: replyingTo?.relays?.takeIf { it.isNotEmpty() }
}
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
} else if (replyGroupId != null) {
// Group content: route to the resolved host. If it couldn't be resolved, publish to the
// parent's relays (possibly empty) rather than broadcasting to the outbox — better to
// under-deliver a group reply than to leak group participation to unrelated relays.
val relays = groupHostRelays ?: replyingTo?.relays.orEmpty()
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { relays }
} else {
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
}
@@ -577,6 +603,12 @@ open class CommentPostViewModel :
msg = tagger.message,
replyingTo = eventHint,
) {
// A reply inside a NIP-29 group thread must carry the group's `h`
// tag, or the host relay won't accept it and no other member (or
// other NIP-29 client, e.g. Flotilla) will see it. Inherit it from
// the event being replied to; a no-op for non-group comments.
replyingToEvent?.groupId()?.let { hTag(it) }
val notifyPTags = tagger.pTags?.let { pTagList -> pTagList.map { it.toPTag() } } ?: emptyList()
val extraNotificationAuthors =
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
@@ -328,6 +329,41 @@ class TopNavFilterState(
)
}
private val _relayGroupsDiscoveryRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
account.relayGroupList.liveRelayGroupServers,
) { peopleLists, interests, joinedServers ->
checkNotInMainThread()
// A relay chip per host relay of every group the user joined (kind-10009), so they can
// browse the OTHER groups on those relays without first having to Favorite them. Deduped
// against relays that are already chips because they were favorited (kind-10012, already
// in `interests`). Only in this catalog — other feeds (git, podcasts, …) don't want them.
val alreadyChip = interests.mapNotNullTo(HashSet()) { (it.code as? TopFilter.Relay)?.url }
val joinedRelayChips =
joinedServers
.asSequence()
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.filter { it.url !in alreadyChip }
.distinctBy { it.url }
.map { FeedDefinition(TopFilter.Relay(it.url), RelayName(it)) }
.sortedBy { it.name.name() }
.toList()
emit(
listOf(
// Relay-group discovery routes to relays by author, hashtag and geohash, plus
// favorited + joined-group relay chips; "Mine" (the joined-groups view) sits last
// in the base group to match the ordering every other feed uses.
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
peopleLists,
interests,
joinedRelayChips,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _podcastRoutes =
combineTransform(
livePeopleListsFlow,
@@ -426,6 +462,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
val relayGroupsDiscoveryRoutes =
_relayGroupsDiscoveryRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
val podcastRoutes =
_podcastRoutes
.flowOn(Dispatchers.IO)
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarCollectionsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.RelayGroupDiscoveryFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter
@@ -113,6 +114,7 @@ class AccountFeedContentStates(
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache)
val gitRepositoriesFeed = FeedContentState(GitRepositoriesFeedFilter(account), scope, LocalCache)
val relayGroupsDiscoveryFeed = FeedContentState(RelayGroupDiscoveryFeedFilter(account), scope, LocalCache)
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
@@ -166,6 +168,28 @@ class AccountFeedContentStates(
}
}
// Same for the NIP-29 joined-group list (kind 10009): joining/leaving changes the list but
// doesn't flow through newEventBundles, so force a rebuild — otherwise a just-joined group
// (whose messages haven't loaded yet) wouldn't appear on the Messages tab until a later event.
scope.launch(Dispatchers.IO) {
account.relayGroupList.liveRelayGroupList
.drop(1)
.collect {
dmKnown.invalidateData()
}
}
// Flipping the NIP-29 view mode (inline groups vs one row per relay) changes what the
// Messages feed emits for joined groups, but no event flows through LocalCache — force a
// full rebuild so the list switches shape immediately.
scope.launch(Dispatchers.IO) {
account.settings.relayGroupViewMode
.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.
@@ -232,6 +256,7 @@ class AccountFeedContentStates(
picturesFeed.updateFeedWith(newNotes)
workoutsFeed.updateFeedWith(newNotes)
gitRepositoriesFeed.updateFeedWith(newNotes)
relayGroupsDiscoveryFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
publicChatsFeed.updateFeedWith(newNotes)
@@ -294,6 +319,7 @@ class AccountFeedContentStates(
picturesFeed.deleteFromFeed(newNotes)
workoutsFeed.deleteFromFeed(newNotes)
gitRepositoriesFeed.deleteFromFeed(newNotes)
relayGroupsDiscoveryFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
publicChatsFeed.deleteFromFeed(newNotes)
@@ -352,6 +378,7 @@ class AccountFeedContentStates(
picturesFeed.trimToSize(maxItems)
workoutsFeed.trimToSize(maxItems)
gitRepositoriesFeed.trimToSize(maxItems)
relayGroupsDiscoveryFeed.trimToSize(maxItems)
calendarAppointmentsFeed.trimToSize(maxItems)
calendarCollectionsFeed.trimToSize(maxItems)
productsFeed.trimToSize(maxItems)
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
@@ -143,6 +144,7 @@ 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.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
@@ -1436,6 +1438,89 @@ class AccountViewModel(
fun unfollow(channel: EphemeralChatChannel) = launchSigner { account.unfollow(channel) }
fun joinRelayGroup(
channel: RelayGroupChannel,
code: String? = null,
) = launchSigner { account.joinRelayGroup(channel, code) }
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
fun createRelayGroup(
relay: NormalizedRelayUrl,
groupId: String,
name: String,
about: String?,
picture: String?,
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.createRelayGroup(
relay,
groupId,
name,
about,
picture,
isPrivate,
isClosed,
isHidden,
isRestricted,
hashtags,
geohashes,
)
}
fun createRelayGroupInvite(
channel: RelayGroupChannel,
code: String,
) = launchSigner { account.createRelayGroupInvite(channel, code) }
fun postRelayGroupThread(
channel: RelayGroupChannel,
title: String,
body: String,
) = launchSigner { account.postRelayGroupThread(channel, title, body) }
fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
) = launchSigner { account.removeRelayGroupUser(channel, pubkey) }
fun putRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
roles: List<String>,
) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) }
fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
about: String?,
picture: String?,
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.editRelayGroupMetadata(
channel,
name,
about,
picture,
isPrivate,
isClosed,
isHidden,
isRestricted,
hashtags,
geohashes,
)
}
fun follow(users: List<User>) = launchSigner { account.follow(users) }
fun follow(user: User) = launchSigner { account.follow(user) }
@@ -1711,6 +1796,12 @@ class AccountViewModel(
fun getEphemeralChatChannelIfExists(key: RoomId) = LocalCache.getEphemeralChatChannelIfExists(key)
fun checkGetOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = LocalCache.getOrCreateRelayGroupChannel(key)
fun getRelayGroupChannelIfExists(key: GroupId) = LocalCache.getRelayGroupChannelIfExists(key)
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl) = LocalCache.getRelayGroupChannelsOnRelay(relay)
fun getLiveActivityChannelIfExists(key: Address) = LocalCache.getLiveActivityChannelIfExists(key)
fun <T : PubKeyReferenceTag> loadParticipants(
@@ -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.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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
@@ -131,6 +132,8 @@ private fun PreloadFor(
NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel)
NavBarItem.RELAY_GROUPS -> RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -42,6 +43,7 @@ class ChannelFromUserFilterSubAssembler(
is EphemeralChatChannel -> filterMyMessagesToEphemeralChat(channel, userHex(key), since)
is PublicChatChannel -> filterMyMessagesToPublicChat(channel, user(key).pubkeyHex, since)
is LiveActivitiesChannel -> filterMyMessagesToLiveActivities(channel, userHex(key), since)
is RelayGroupChannel -> filterMyMessagesToRelayGroup(channel, userHex(key), since)
else -> null
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -52,6 +53,11 @@ class ChannelPublicFilterSubAssembler(
filterGoalForLiveActivities(channel, since)
}
is RelayGroupChannel -> {
filterMessagesToRelayGroup(channel, since) +
filterMetadataToRelayGroup(channel, since)
}
else -> {
null
}
@@ -0,0 +1,55 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** Timeline kinds shown in a NIP-29 group: chat messages and polls. */
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* The message timeline for a NIP-29 group. A group's chat lives entirely on its
* host relay, scoped by the `h` tag, so the filter is pinned to
* [RelayGroupChannel.relays] (always the single host) and never fans out to the
* user's other relays.
*/
fun filterMessagesToRelayGroup(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
limit = 200,
since = since?.get(it)?.time,
),
)
}
@@ -0,0 +1,61 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
/** Relay-signed group directory kinds: metadata + admins + members + roles. */
private val RELAY_GROUP_METADATA_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
)
/**
* The relay-signed metadata for a NIP-29 group (name/picture/about + admin,
* member and role lists), addressed by the group id (`d` tag) and pinned to the
* group's host relay. The relay signs these with its own key, so a single-relay
* query scoped by `#d` returns exactly this group's directory.
*/
fun filterMetadataToRelayGroup(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to listOf(channel.groupId.id)),
since = since?.get(it)?.time,
),
)
}
@@ -0,0 +1,57 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* The current user's own messages in a NIP-29 group. A smaller companion to
* [filterMessagesToRelayGroup] that back-fills the user's sent messages (so
* optimistic sends reconcile) — pinned to the group's host relay and scoped by
* both the `h` group tag and the author.
*/
fun filterMyMessagesToRelayGroup(
channel: RelayGroupChannel,
pubKey: HexKey,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
authors = listOf(pubKey),
limit = 50,
since = since?.get(it)?.time,
),
)
}
@@ -0,0 +1,138 @@
/*
* 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.relayGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.launch
/**
* Mint a NIP-29 invite (kind 9009) for the group as soon as the dialog opens and
* show the code to copy and share. Requires admin/moderator rights on the relay.
*/
@Composable
fun InviteRelayGroupDialog(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val code by remember { mutableStateOf(RandomInstance.bytes(6).toHexKey()) }
// Observe the relay-signed metadata so the naddr / closed-state fill in live: the
// dialog is often opened before the kind-39000 has arrived, and reading a snapshot
// there would leave the Copy button permanently disabled once it does load.
val channelState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
// A shareable, cross-client coordinate for the group (opens the chat in any
// NIP-29 client). Null until the relay-signed metadata has loaded.
val nAddr = liveChannel.toNAddr()?.let { "nostr:$it" }
val isClosed = liveChannel.isClosed()
// A join code is only meaningful for closed (invite-only) groups; open groups
// join directly from the shared naddr. So mint the kind-9009 invite only when
// the group is actually closed, rather than on every dialog open.
LaunchedEffect(code, isClosed) {
if (isClosed) accountViewModel.createRelayGroupInvite(channel, code)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.relay_group_invite_title)) },
text = {
Column {
Text(stringRes(R.string.relay_group_invite_description))
if (nAddr != null) {
Text(
text = nAddr,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
)
} else {
// Metadata (the group's pubkey, needed for the naddr) hasn't loaded yet.
Text(
text = stringRes(R.string.relay_group_invite_preparing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Closed groups additionally need a one-time code to join.
if (isClosed) {
Text(stringRes(R.string.relay_group_invite_code_label))
Text(
text = code,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
},
confirmButton = {
// Copy the group link, plus the code when the group is closed (so a
// recipient has both to join). Never fall back to copying a code the
// dialog didn't show — for an open group with metadata not yet loaded
// there is simply nothing to copy, so disable the button.
val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n")
TextButton(
enabled = toCopy.isNotBlank(),
onClick = {
scope.launch { clipboard.setText(toCopy) }
onDismiss()
},
) {
Text(stringRes(R.string.copy))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@@ -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.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Ask for an invite code before joining a closed (invite-only) NIP-29 group, then
* send the join request (kind 9021) carrying the code. Open groups don't need this
* — they join directly.
*/
@Composable
fun JoinRelayGroupDialog(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
onJoined: () -> Unit,
onDismiss: () -> Unit,
) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.relay_group_join_title)) },
text = {
Column {
Text(stringRes(R.string.relay_group_join_code_hint))
OutlinedTextField(
value = code,
onValueChange = { code = it },
singleLine = true,
label = { Text(stringRes(R.string.relay_group_join_code_label)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(
enabled = code.isNotBlank(),
onClick = {
accountViewModel.joinRelayGroup(channel, code.trim())
onJoined()
onDismiss()
},
) {
Text(stringRes(R.string.relay_group_join_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@@ -0,0 +1,41 @@
/*
* 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.relayGroup
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.note.produceStateIfNotNull
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@Composable
fun LoadRelayGroupChannel(
id: GroupId,
accountViewModel: AccountViewModel,
content: @Composable (RelayGroupChannel) -> Unit,
) {
val channel =
produceStateIfNotNull(accountViewModel.getRelayGroupChannelIfExists(id), id) {
value = accountViewModel.checkGetOrCreateRelayGroupChannel(id)
}
channel.value?.let { content(it) }
}
@@ -0,0 +1,214 @@
/*
* 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.relayGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelaySuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.ShowRelaySuggestionList
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* A handful of widely-used public NIP-29 relays to seed discovery for a user who
* hasn't joined any group yet. Not exhaustive and not an endorsement — just a
* starting point; anyone can paste any relay URL above.
*/
private val POPULAR_RELAYS =
listOf(
"wss://groups.0xchat.com",
"wss://communities.nos.social",
)
/**
* Discovery entry point for NIP-29 groups: paste any relay URL to browse the
* channels it hosts (opening [Route.RelayGroupServer]), pick from relays you're
* already on, or try a popular public relay. This is the "find new groups" flow
* that the joined-only Messages list can't offer.
*/
@Composable
fun RelayGroupBrowseScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
var relayUrl by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
// Same known-relay autocomplete every other relay field uses: filters the relays the app has
// already seen (LocalCache.relayHints) by the substring being typed.
val relaySuggestions = remember { RelaySuggestionState() }
val suggestionResults by relaySuggestions.results.collectAsStateWithLifecycle(emptyList())
val joined by accountViewModel.account.relayGroupList.liveRelayGroupServers
.collectAsStateWithLifecycle()
fun open(url: String) {
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())
if (normalized == null) {
showError = true
return
}
relaySuggestions.reset()
nav.nav(Route.RelayGroupServer(normalized.url))
}
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = {
Text(
text = stringRes(R.string.relay_group_browse_title),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
popBack = nav::popBack,
)
},
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringRes(R.string.relay_group_browse_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
OutlinedTextField(
value = relayUrl,
onValueChange = {
relayUrl = it
showError = false
relaySuggestions.processInput(it)
},
singleLine = true,
isError = showError,
supportingText =
if (showError) {
{ Text(stringRes(R.string.relay_group_browse_invalid_url)) }
} else {
null
},
label = { Text(stringRes(R.string.relay_group_browse_relay_label)) },
placeholder = { Text("wss://…") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { open(relayUrl) }),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
// Autocomplete popup: tapping a match opens that relay's group directory directly.
if (suggestionResults.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
elevation = cardElevation(5.dp),
shape = PopupUpEffect,
) {
ShowRelaySuggestionList(
relaySuggestions = relaySuggestions,
onSelect = { relay -> open(relay.url) },
modifier = HalfHorzPadding,
nip11CachedRetriever = Amethyst.instance.nip11Cache,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Button(
onClick = { open(relayUrl) },
enabled = relayUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(stringRes(R.string.relay_group_browse_go))
}
if (joined.isNotEmpty()) {
SectionHeader(stringRes(R.string.relay_group_browse_your_relays))
joined.sorted().forEach { server ->
RelayGroupServerRow(server, accountViewModel) { open(server) }
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
// `joined` holds normalized URLs (trailing-slash form); normalize the
// literals before comparing so an already-joined popular relay is hidden.
val joinedNormalized = joined.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it)?.url }
val suggestions = POPULAR_RELAYS.filter { RelayUrlNormalizer.normalizeOrNull(it)?.url !in joinedNormalized }
if (suggestions.isNotEmpty()) {
SectionHeader(stringRes(R.string.relay_group_browse_popular))
suggestions.forEach { server ->
RelayGroupServerRow(server, accountViewModel) { open(server) }
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
@@ -0,0 +1,251 @@
/*
* 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.relayGroup
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.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.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.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/** A first screen's worth of recent messages to prefetch per visible group card, ahead of a tap. */
private const val CHANNEL_LIST_WARMUP_LIMIT = 10
/**
* Lists every channel a relay hosts (its kind 39000-39003 directory), so the user
* can browse and open channels on that relay. The relay's directory is streamed by
* [RelayGroupsOnRelaySubscription] and consumed into per-group channels; this
* screen reads them back for the relay and renders them. Each visible card also warms
* its group's recent messages so opening a chat lands on cached content.
*/
@Composable
fun RelayGroupChannelListScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel)
// Warm the relay's NIP-11 so we can tell its genuine (relay-signed) groups from stray
// user-published 39000s that a non-NIP-29 relay may also be storing.
val relayInfo by loadRelayInfo(relay)
// Re-read the relay's channels whenever a group-metadata (kind 39000) event lands in
// the cache — driven by LocalCache.observeEvents rather than a timer, so the list
// updates as directory events arrive with no polling. The initial value is sorted too
// so the first frame doesn't reshuffle when the first emission arrives.
val allChannels by produceState(
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() },
relay,
) {
LocalCache
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.collect {
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }
}
}
// Only the relay's own genuine, relay-signed groups (39000 author == the relay's NIP-11 `self`).
// Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden.
val channels = remember(allChannels, relayInfo) { allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } }
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = {
Text(
text = relay.displayUrl(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
popBack = nav::popBack,
)
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
}
},
) { padding ->
val myPubkey = accountViewModel.userProfile().pubkeyHex
if (channels.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
text = stringRes(R.string.relay_group_channels_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(32.dp),
)
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel ->
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) }
}
}
}
}
}
@Composable
private fun RelayGroupChannelRow(
channel: RelayGroupChannel,
myPubkey: String,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val joined = channel.membershipOf(myPubkey).isMember()
val memberCount = channel.memberCount()
// Anticipate a tap: while this row is on-screen, prefetch a first screen's worth of recent
// messages for its group (content only — the directory subscription already streams metadata),
// so opening the chat lands on cached content instead of a blank load. Bounded to visible rows
// by the LazyColumn, and released as they scroll off.
RelayGroupWarmupSubscription(
channel,
accountViewModel.dataSources().relayGroupWarmup,
accountViewModel,
contentOnly = true,
contentLimit = CHANNEL_LIST_WARMUP_LIMIT,
)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
modifier = Modifier.size(40.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (channel.isPrivate()) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
}
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val subtitle =
channel.summary()?.takeIf { it.isNotBlank() }
?: if (memberCount > 0) {
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
} else {
null
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (joined) {
Box(Modifier.size(20.dp).clip(CircleShape)) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = stringRes(R.string.relay_group_role_member),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
@@ -0,0 +1,203 @@
/*
* 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.relayGroup
import androidx.compose.foundation.layout.Column
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.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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
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.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@Composable
fun RelayGroupChannelView(
channelId: GroupId?,
draft: Note? = null,
replyTo: Note? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
LoadRelayGroupChannel(channelId, accountViewModel) { group ->
PrepareChannelViewModels(
baseChannel = group,
draft = draft,
replyTo = replyTo,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun PrepareChannelViewModels(
baseChannel: RelayGroupChannel,
draft: Note? = null,
replyTo: Note? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedViewModel: ChannelFeedViewModel =
viewModel(
key = baseChannel.groupId.toKey() + "ChannelFeedViewModel",
factory =
ChannelFeedViewModel.Factory(
baseChannel,
accountViewModel.account,
),
)
val channelScreenModel: ChannelNewMessageViewModel = viewModel()
channelScreenModel.init(accountViewModel)
channelScreenModel.load(baseChannel)
if (draft != null) {
LaunchedEffect(draft, channelScreenModel, accountViewModel) {
channelScreenModel.editFromDraft(draft)
}
}
if (replyTo != null) {
LaunchedEffect(replyTo, channelScreenModel, accountViewModel) {
channelScreenModel.reply(replyTo)
}
}
ChannelView(
channel = baseChannel,
feedViewModel = feedViewModel,
newPostModel = channelScreenModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
private fun ChannelView(
channel: RelayGroupChannel,
feedViewModel: ChannelFeedViewModel,
newPostModel: ChannelNewMessageViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel)
Column(Modifier.fillMaxHeight()) {
Column(
modifier =
remember {
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
},
) {
RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "RelayGroup/${channel.groupId.toKey()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
)
}
Spacer(modifier = DoubleVertSpacer)
// NIP-29 relays reject writes from non-members, so only show the composer when the
// relay-signed roster (39001/39002) lists me as a member/mod/admin. Collect the metadata
// flow so the composer appears the moment my join is accepted. Otherwise, explain why.
val channelState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
val canPost = liveChannel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember()
if (canPost) {
EditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = feedViewModel.feedState::sendToTop,
nav,
)
} else {
JoinToPostNotice(liveChannel)
}
}
}
/**
* Shown in place of the composer when I'm not (yet) a member: a relay group won't accept my kind-9
* chat until its roster lists me, so typing would only earn a silent relay rejection. Points me at
* the top-bar Join action (open groups) or explains the invite requirement (closed groups).
*/
@Composable
private fun JoinToPostNotice(channel: RelayGroupChannel) {
val message =
if (channel.isClosed()) {
stringRes(R.string.relay_group_invite_only_to_post)
} else {
stringRes(R.string.relay_group_join_to_post)
}
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
@@ -0,0 +1,64 @@
/*
* 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.relayGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@Composable
fun RelayGroupChatScreen(
id: HexKey,
relayUrl: String,
draftId: HexKey? = null,
replyToId: HexKey? = null,
inviteCode: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val channelId = remember(id, relay) { GroupId(id, relay) }
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
LoadRelayGroupChannel(channelId, accountViewModel) {
RelayGroupTopBar(it, inviteCode, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it)) {
RelayGroupChannelView(channelId, draft, replyTo, accountViewModel, nav)
}
}
}
@@ -0,0 +1,596 @@
/*
* 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.relayGroup
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
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.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.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
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.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* Discover NIP-29 groups across the relay set the top-bar filter resolves to. Built on the shared
* feed stack exactly like the Git-repositories screen: a [FeedContentState]
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates.relayGroupsDiscoveryFeed])
* fed by the per-type datasource, a [FeedFilterSpinner] persisting the selection to
* `defaultRelayGroupsDiscoveryFollowList`, and a [RefresheableBox] + [RenderFeedContentState]. Rows
* are the relay-signed 39000 metadata notes, rendered as joinable group cards.
*/
@Composable
fun RelayGroupDiscoveryScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayGroupDiscoveryScreen(
feedContentState = accountViewModel.feedStates.relayGroupsDiscoveryFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun RelayGroupDiscoveryScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedContentState)
WatchAccountForRelayGroupDiscovery(feedContentState, accountViewModel)
RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel)
// Keep the joined groups' metadata + rosters live so the "My Groups" filter can list them
// (their host relays aren't fetched by the discovery filter set).
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = { RelayGroupsDiscoveryTopBar(accountViewModel, nav) },
bottomBar = {
AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route ->
if (route == Route.RelayGroups) feedContentState.sendToTop() else nav.navBottomBar(route)
}
},
floatingButton = {
FabBottomBarPadded(nav) {
FloatingActionButton(
onClick = { nav.nav(Route.RelayGroupBrowse) },
shape = CircleShape,
) {
Icon(
symbol = MaterialSymbols.Link,
contentDescription = stringRes(R.string.relay_group_browse_title),
)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.RELAY_GROUPS_DISCOVERY_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = feedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = null,
onLoaded = { loaded ->
val items by loaded.feed.collectAsStateWithLifecycle()
// Collect the kind-3 follow set once for the whole list; each card highlights
// the members it contains ("people you follow who are in here").
val follows by accountViewModel.account.kind3FollowList.flow
.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier = Modifier.fillMaxWidth(),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
RelayGroupDiscoveryRow(item, Modifier.animateItem(), follows.authors, accountViewModel, nav)
}
}
},
)
}
}
}
}
/**
* Root top bar for the Relay Groups tab — the shared drawer/search bar every top-level feed uses,
* with the feed-filter spinner as its title (mirrors GitRepositoriesTopBar). The browse-a-relay
* action lives on the FAB, not here, so the bar stays consistent with the other screens.
*/
@Composable
private fun RelayGroupsDiscoveryTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
.collectAsStateWithLifecycle()
val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes
.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = selectedFilter,
explainer = stringRes(R.string.select_list_to_filter),
options = options,
onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList,
accountViewModel = accountViewModel,
)
}
}
@Composable
private fun WatchAccountForRelayGroupDiscovery(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listName by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
.collectAsStateWithLifecycle()
val perRelay by accountViewModel.account.liveRelayGroupsDiscoveryFollowListsPerRelay
.collectAsStateWithLifecycle()
// The "My Groups" filter reads the kind-10009 joined list, so re-scan when it changes
// (join/leave) — otherwise a newly-joined group wouldn't appear until another refresh.
val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
val joinedServers by accountViewModel.account.relayGroupList.liveRelayGroupServers
.collectAsStateWithLifecycle()
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
.collectAsStateWithLifecycle()
// Discovery only shows groups whose 39000 is signed by the host relay's own key (NIP-29's
// authority — the NIP-11 `self` pubkey; general relays instead carry stray user-published 39000s
// that can't be joined). The feed reads that from each relay's cached NIP-11, so warm every
// candidate relay (in parallel — WarmNip11) and re-invalidate as each doc lands, otherwise a
// relay whose NIP-11 resolves after its 39000s would stay hidden until a manual refresh.
//
// The follow-list filter sets resolve relays via the outbox model, but a group's 39000 lives on
// its HOST relay. In All-Follows the subassembler probes those host relays (joined kind-10009 +
// favorited kind-10012) for follow rosters, so warm them here too — otherwise their groups get
// fetched but the self-key gate reads an unwarmed (empty, self=null) NIP-11 and hides them until
// the user bounces through Global (whose relay set happens to include them).
val candidateRelays =
remember(perRelay, joinedServers, favoriteRelays) {
buildSet {
addAll(perRelay.toGroupConstraints().keys)
joinedServers.forEach { server -> RelayUrlNormalizer.normalizeOrNull(server)?.let(::add) }
addAll(favoriteRelays)
}
}
var nip11Version by remember { mutableIntStateOf(0) }
WarmNip11(candidateRelays) { nip11Version++ }
LaunchedEffect(listName, perRelay, joinedGroups) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
// A NIP-11 doc resolving doesn't change the feed key (the self-key test lives in the filter's
// match, not the key), so force a re-filter directly as each one lands.
LaunchedEffect(nip11Version) {
feedContentState.invalidateData()
}
}
/**
* Resolves the 39000 metadata note to its live [RelayGroupChannel] (keyed by host relay + group
* id) and recomposes in place as the relay-signed metadata / roster changes, so member counts and
* membership stay current without moving the row. While the row is on screen it also warms the
* group — the newest handful of chat messages / threads — so tapping it opens an already-populated
* screen. The warm-up is lifecycle-aware and tears down as the row scrolls off.
*/
@Composable
private fun RelayGroupDiscoveryRow(
note: Note,
modifier: Modifier,
follows: Set<HexKey>,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Resolve the note to its channel the SAME way the feed filter matched/sorted it, so the row
// never binds a different relay's (empty) channel than the one the feed qualified.
val baseChannel = remember(note) { relayGroupDiscoveryChannelFor(note) } ?: return
// Prefetch the group's recent content so opening the card lands on a populated screen. The
// metadata/rosters are already streaming from the directory subscription, so only ask for
// content here (contentOnly) instead of re-requesting 39000-39003 per visible row.
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel, contentOnly = true)
val channelState by baseChannel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
// Reactive loaded-message count: the preview subscription streams kind-9 chats into the
// channel's note cache, and each arrival re-emits this flow, so the "50+ messages" activity
// signal grows live while the row is on screen.
val notesState by baseChannel
.flow()
.notes.stateFlow
.collectAsStateWithLifecycle()
val messageCount = notesState.channel.notes.size()
// People I follow who are in this group (relay-signed roster ∩ my kind-3 follows). Recomputed
// when the roster changes (channelState) or my follow list does.
val participatingFollows = remember(channelState, follows) { channel.participatingFollows(follows) }
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
.collectAsStateWithLifecycle()
RelayGroupDiscoveryCard(
channel = channel,
modifier = modifier,
myPubkey = accountViewModel.userProfile().pubkeyHex,
isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays,
messageCount = messageCount,
participatingFollows = participatingFollows,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
private fun RelayGroupDiscoveryCard(
channel: RelayGroupChannel,
modifier: Modifier,
myPubkey: String,
isFavoriteRelay: Boolean,
messageCount: Int,
participatingFollows: List<HexKey>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val isMember = channel.membershipOf(myPubkey).isMember()
val memberCount = channel.memberCount()
val relay = channel.groupId.relayUrl
ElevatedCard(
onClick = { nav.nav(routeFor(channel)) },
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp),
) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
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)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = channel.toBestDisplayName(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
DiscoveryStatusPill(channel)
}
if (memberCount > 0 || messageCount > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (memberCount > 0) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (memberCount > 0 && messageCount > 0) {
Text(
text = "·",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (messageCount > 0) {
// Loaded from the preview page; a full page reads as "50+", fewer as
// the exact count. DISCOVERY_MESSAGE_CAP tracks the preview limit.
Text(
text =
if (messageCount >= DISCOVERY_MESSAGE_CAP) {
pluralStringResource(R.plurals.relay_group_message_count_capped, DISCOVERY_MESSAGE_CAP, DISCOVERY_MESSAGE_CAP)
} else {
pluralStringResource(R.plurals.relay_group_message_count, messageCount, messageCount)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (!isMember) {
FilledTonalButton(onClick = {
// Open groups join directly; closed ones open the group where the invite
// code can be entered.
if (channel.isClosed()) {
nav.nav(routeFor(channel))
} else {
accountViewModel.joinRelayGroup(channel)
}
}) {
Text(stringRes(R.string.join))
}
}
}
// Prominent social proof: the people I follow who are already in this group.
if (participatingFollows.isNotEmpty()) {
FollowsInGroupRow(
follows = participatingFollows,
accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.padding(top = 10.dp),
)
}
// The host relay as its own chip. Tapping the chip opens that relay's full group list;
// the star INSIDE it is a separate tap target that only favorites the relay (surfacing
// its groups under the relay filter). Joining the group is the separate button above.
RelayChip(
relay = relay,
isFavorite = isFavoriteRelay,
onToggleFavorite = {
if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay)
},
onOpenRelay = { nav.nav(Route.RelayGroupServer(relay.url)) },
modifier = Modifier.padding(top = 8.dp),
)
channel.summary()?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
}
}
}
/**
* The group's host relay as a tappable chip. The chip body opens that relay's full group list
* ([Route.RelayGroupServer]); the star inside it is an independent tap target that only toggles the
* relay in the kind-10012 relay-feeds list (a favorited relay, and every group it hosts, then
* surfaces under the relay chip in the top-nav filter). Filled/primary when favorited, tonal outline
* otherwise — visually distinct from the group's Join button so the scopes don't read as one action.
*/
@Composable
private fun RelayChip(
relay: NormalizedRelayUrl,
isFavorite: Boolean,
onToggleFavorite: () -> Unit,
onOpenRelay: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.fillMaxWidth()) {
Surface(
onClick = onOpenRelay,
shape = RoundedCornerShape(8.dp),
color =
if (isFavorite) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier.weight(1f, fill = false),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(start = 2.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
) {
Icon(
symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder,
contentDescription = stringRes(R.string.relay_group_favorite_relay),
tint =
if (isFavorite) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier =
Modifier
.clip(CircleShape)
.clickable(onClick = onToggleFavorite)
.padding(4.dp)
.size(16.dp),
)
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.labelMedium,
color =
if (isFavorite) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* Overlapping avatars of the people I follow who are in the group, with a "%d people you follow"
* caption — social proof that a group is worth joining. Shows up to five faces; the caption always
* carries the true total.
*/
@Composable
private fun FollowsInGroupRow(
follows: List<HexKey>,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy((-9).dp)) {
follows.take(5).forEach { pubkey ->
UserPicture(
userHex = pubkey,
size = Size25dp,
pictureModifier = Modifier.border(1.5.dp, MaterialTheme.colorScheme.surface, CircleShape),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Text(
text = pluralStringResource(R.plurals.relay_group_follows_participating, follows.size, follows.size),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* Display cap for the loaded-message counter — kept in step with the discovery preview's fetch
* limit (RELAY_GROUP_WARMUP_LIMIT), so a chat that returns the full page reads as "50+ messages".
*/
private const val DISCOVERY_MESSAGE_CAP = 50
/** Small tonal pill: Invite-only wins over Private, matching the inline group card. */
@Composable
private fun DiscoveryStatusPill(channel: RelayGroupChannel) {
val label =
when {
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
else -> return
}
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.secondaryContainer) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
@@ -0,0 +1,334 @@
/*
* 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.relayGroup
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.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.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 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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
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.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* The roster of a NIP-29 group: everyone the relay lists as an admin (kind 39001)
* or member (kind 39002), each with their role badge. A moderator (admin or
* moderator role) additionally gets a per-user overflow menu to promote/demote
* (kind 9000 put-user) and kick (kind 9001 remove-user); the host relay enforces
* the actual permission, this UI just surfaces the actions.
*/
@Composable
fun RelayGroupMembersScreen(
id: HexKey,
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val channelId = remember(id, relay) { GroupId(id, relay) }
LoadRelayGroupChannel(channelId, accountViewModel) { channel ->
RelayGroupMembers(channel, accountViewModel, nav)
}
}
private class RosterEntry(
val pubkey: HexKey,
val membership: RelayGroupMembership,
)
@Composable
private fun RelayGroupMembers(
baseChannel: RelayGroupChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Fetch the relay-signed roster (kinds 39001/39002) from the host relay while this
// standalone screen is open. observeChannel alone does NOT do this for a relay group
// (its finder only handles public-chat / live-activity channels), so without this the
// screen would only show whatever the chat screen happened to cache.
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
// Recompose when the relay-signed roster (39001/39002) changes.
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val myPubkey = accountViewModel.userProfile().pubkeyHex
val iCanModerate = channel.membershipOf(myPubkey).canModerate()
val iAmAdmin = channel.membershipOf(myPubkey) == RelayGroupMembership.ADMIN
// Admins/moderators first, then plain members; alphabetical only inside a rank
// is overkill here — rely on relay order but push elevated roles to the top.
val roster =
remember(channel.admins, channel.members) {
val everyone = (channel.admins.map { it.pubKey } + channel.members).distinct()
everyone
.map { RosterEntry(it, channel.membershipOf(it)) }
.sortedBy { it.membership.rank() }
}
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = {
Column {
Text(
text = stringRes(R.string.relay_group_members_title),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = channel.toBestDisplayName(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
popBack = nav::popBack,
)
},
) { padding ->
if (roster.isEmpty()) {
// The roster always has at least the group's admins once it loads, so an
// empty list means the kind-39001/39002 events haven't arrived yet.
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
itemsIndexed(roster, key = { _, entry -> entry.pubkey }) { index, entry ->
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
RelayGroupMemberRow(
entry = entry,
channel = channel,
isSelf = entry.pubkey == myPubkey,
viewerCanModerate = iCanModerate,
viewerIsAdmin = iAmAdmin,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
/** Sort key so admins float above moderators above plain members. */
private fun RelayGroupMembership.rank(): Int =
when (this) {
RelayGroupMembership.ADMIN -> 0
RelayGroupMembership.MODERATOR -> 1
else -> 2
}
@Composable
private fun RelayGroupMemberRow(
entry: RosterEntry,
channel: RelayGroupChannel,
isSelf: Boolean,
viewerCanModerate: Boolean,
viewerIsAdmin: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Create-or-get (never a one-shot null): UsernameDisplay observes the user's
// metadata flow, so the name fills in when the kind:0 arrives instead of being
// stuck on truncated hex forever.
val user = remember(entry.pubkey) { accountViewModel.checkGetOrCreateUser(entry.pubkey) }
var menuOpen by remember { mutableStateOf(false) }
var confirmRemove by remember { mutableStateOf(false) }
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(
text = entry.pubkey.take(8),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
MemberRoleBadge(entry.membership)
// Moderators can act on others (not themselves); the relay is the final
// authority, but hide obviously-useless menus (a moderator can't touch an admin).
val canActOnTarget =
viewerCanModerate &&
!isSelf &&
(viewerIsAdmin || entry.membership != RelayGroupMembership.ADMIN)
if (canActOnTarget) {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_make_admin)) },
onClick = {
menuOpen = false
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN))
},
)
}
if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_make_moderator)) },
onClick = {
menuOpen = false
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR))
},
)
}
if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_demote_member)) },
onClick = {
menuOpen = false
accountViewModel.putRelayGroupUser(channel, entry.pubkey, emptyList())
},
)
}
DropdownMenuItem(
text = {
Text(
text = stringRes(R.string.relay_group_remove_user),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuOpen = false
confirmRemove = true
},
)
}
}
}
if (confirmRemove) {
val displayName = user?.toBestDisplayName() ?: entry.pubkey.take(8)
AlertDialog(
onDismissRequest = { confirmRemove = false },
title = { Text(stringRes(R.string.relay_group_remove_user)) },
text = { Text(stringRes(R.string.relay_group_remove_user_confirm, displayName)) },
confirmButton = {
TextButton(onClick = {
confirmRemove = false
accountViewModel.removeRelayGroupUser(channel, entry.pubkey)
}) {
Text(stringRes(R.string.relay_group_remove_user), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmRemove = false }) {
Text(stringRes(R.string.cancel))
}
},
)
}
}
/** A small colored pill for an elevated role; plain members get nothing. */
@Composable
private fun MemberRoleBadge(membership: RelayGroupMembership) {
val label =
when (membership) {
RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin)
RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator)
else -> return
}
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
@@ -0,0 +1,444 @@
/*
* 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.relayGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
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.produceState
import androidx.compose.runtime.remember
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* Create a new NIP-29 group on [relayUrl]: publishes kind 9007 + 9002 with every
* metadata field the user sets (name, description, picture, and the four status
* flags), then opens the new group. The user becomes its first admin.
*/
@Composable
fun RelayGroupCreateScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl")
LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) }
// A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as
// ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation
// on the relay advertising NIP-29 in its NIP-11 `supported_nips`. Tri-state: null = still checking,
// false = confirmed unsupported (or unreachable), true = advertised.
val nip29Support by produceState<Boolean?>(initialValue = null, relay) {
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = relay,
onInfo = { info -> value = info.supported_nips?.any { it == "29" } ?: false },
onError = { _, _, _ -> value = false },
)
}
RelayGroupMetadataScaffold(
viewModel = viewModel,
accountViewModel = accountViewModel,
nav = nav,
nip29Support = nip29Support,
onSuccess = { nav.popUpTo(Route.RelayGroup(viewModel.groupId, relay.url), Route.RelayGroupCreate::class) },
)
}
/**
* Edit a group's relay-signed metadata (kind 9002, admin only). Pre-fills from the
* group's current metadata reactively (so a late first load still populates), then
* saves and returns.
*/
@Composable
fun RelayGroupEditScreen(
id: String,
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val groupId = remember(id, relay) { GroupId(id, relay) }
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupEdit:${groupId.toKey()}")
LoadRelayGroupChannel(groupId, accountViewModel) { channel ->
// Keep the relay-signed metadata fresh while editing so a late load prefills.
RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
val channelState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
LaunchedEffect(liveChannel.groupId) { viewModel.initEdit(accountViewModel, liveChannel) }
// Re-seed when the metadata event changes, unless the user has already edited.
LaunchedEffect(liveChannel.event?.id) { viewModel.prefillFrom(liveChannel) }
RelayGroupMetadataScaffold(
viewModel = viewModel,
accountViewModel = accountViewModel,
nav = nav,
onSuccess = { nav.popBack() },
)
}
}
@Composable
private fun RelayGroupMetadataScaffold(
viewModel: RelayGroupMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
onSuccess: () -> Unit,
// Whether the target relay advertises NIP-29. null = still checking, false = confirmed
// unsupported (block + warn), true = supported. Only meaningful when creating; editing an
// existing group already implies a working relay, so callers pass true.
nip29Support: Boolean? = true,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
var wantsToPickImage by remember { mutableStateOf(false) }
if (wantsToPickImage) {
GallerySelectSingle(
onImageUri = { media ->
wantsToPickImage = false
if (media != null) viewModel.pickMedia(media)
},
)
}
val onSubmit: () -> Unit = {
viewModel.submit(
context = context,
onSuccess = onSuccess,
onError = accountViewModel.toastManager::toast,
)
}
Scaffold(
topBar = {
if (viewModel.isNewGroup) {
CreatingTopBar(
titleRes = R.string.relay_group_create_title,
isActive = { viewModel.canPost && nip29Support == true },
onCancel = nav::popBack,
onPost = onSubmit,
)
} else {
SavingTopBar(
titleRes = R.string.relay_group_edit_title,
isActive = viewModel::canPost,
onCancel = nav::popBack,
onPost = onSubmit,
)
}
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
if (nip29Support == false) {
NoNip29Warning()
Spacer(Modifier.height(16.dp))
}
GroupImagePicker(viewModel) { wantsToPickImage = true }
Spacer(Modifier.height(16.dp))
GroupMetadataFields(viewModel)
}
}
}
}
/** A warning shown when the target relay doesn't advertise NIP-29, blocking creation. */
@Composable
private fun NoNip29Warning() {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(20.dp),
)
Text(
text = stringRes(R.string.relay_group_relay_no_nip29),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
/** The tap-to-upload group avatar hero at the top of the form. */
@Composable
private fun GroupImagePicker(
viewModel: RelayGroupMetadataViewModel,
onPick: () -> Unit,
) {
val picked = viewModel.pickedMedia
val currentUrl = viewModel.picture.value.text
val model: Any? = picked?.uri ?: currentUrl.ifBlank { null }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (model != null) {
AsyncImage(
model = model,
contentDescription = stringRes(R.string.relay_group_field_picture),
contentScale = ContentScale.Crop,
modifier =
Modifier
.size(128.dp)
.clip(CircleShape)
.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
.clickable(onClick = onPick),
)
} else {
Box(
modifier =
Modifier
.size(128.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.clickable(onClick = onPick),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AddPhotoAlternate,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(44.dp),
)
}
}
Spacer(Modifier.height(8.dp))
Text(
text =
if (model != null) {
stringRes(R.string.relay_group_change_photo)
} else {
stringRes(R.string.relay_group_add_photo)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onPick),
)
}
}
@Composable
private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
OutlinedTextField(
value = viewModel.name.value,
onValueChange = {
viewModel.name.value = it
viewModel.markTouched()
},
singleLine = true,
label = { Text(stringRes(R.string.relay_group_field_name)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = viewModel.about.value,
onValueChange = {
viewModel.about.value = it
viewModel.markTouched()
},
label = { Text(stringRes(R.string.relay_group_field_about)) },
minLines = 2,
maxLines = 6,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringRes(R.string.relay_group_section_discovery),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.relay_group_section_discovery_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
OutlinedTextField(
value = viewModel.topics.value,
onValueChange = {
viewModel.topics.value = it
viewModel.markTouched()
},
singleLine = true,
label = { Text(stringRes(R.string.relay_group_field_topics)) },
placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = viewModel.geohash.value,
onValueChange = {
viewModel.geohash.value = it
viewModel.markTouched()
},
singleLine = true,
label = { Text(stringRes(R.string.relay_group_field_geohash)) },
placeholder = { Text(stringRes(R.string.relay_group_field_geohash_hint)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringRes(R.string.relay_group_section_permissions),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
LabeledSwitchRow(
label = stringRes(R.string.relay_group_flag_private),
description = stringRes(R.string.relay_group_flag_private_desc),
checked = viewModel.isPrivate,
) {
viewModel.isPrivate = it
viewModel.markTouched()
}
LabeledSwitchRow(
label = stringRes(R.string.relay_group_flag_invite_only),
description = stringRes(R.string.relay_group_flag_invite_only_desc),
checked = viewModel.isClosed,
) {
viewModel.isClosed = it
viewModel.markTouched()
}
LabeledSwitchRow(
label = stringRes(R.string.relay_group_flag_restricted),
description = stringRes(R.string.relay_group_flag_restricted_desc),
checked = viewModel.isRestricted,
) {
viewModel.isRestricted = it
viewModel.markTouched()
}
LabeledSwitchRow(
label = stringRes(R.string.relay_group_flag_hidden),
description = stringRes(R.string.relay_group_flag_hidden_desc),
checked = viewModel.isHidden,
) {
viewModel.isHidden = it
viewModel.markTouched()
}
}
@Composable
private fun LabeledSwitchRow(
label: String,
description: String,
checked: Boolean,
onChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
@@ -0,0 +1,319 @@
/*
* 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.relayGroup
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Backs the create/edit NIP-29 group metadata screens. Holds the full editable metadata
* (name, about, picture, and the four status flags), lets the user pick a picture from the
* gallery, uploads it to their configured media server on submit, then publishes the kind
* 9007+9002 (create) or 9002 (edit) events. Mirrors [EmojiPackMetadataViewModel].
*/
@Stable
class RelayGroupMetadataViewModel : ViewModel() {
private lateinit var account: Account
/** Non-null in edit mode; null while creating a new group. */
private var channel: RelayGroupChannel? = null
val isNewGroup by derivedStateOf { channel == null }
/** Host relay (create + edit) and the group id (generated in create mode). */
var relay: NormalizedRelayUrl? = null
private set
var groupId: String = ""
private set
val name = mutableStateOf(TextFieldValue())
val about = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
/** Comma/space-separated topic hashtags; drives the discovery hashtag filter. */
val topics = mutableStateOf(TextFieldValue())
/** A single geohash for the group's location; drives the discovery geo filter. */
val geohash = mutableStateOf(TextFieldValue())
var isPrivate by mutableStateOf(false)
var isClosed by mutableStateOf(false)
var isHidden by mutableStateOf(false)
var isRestricted by mutableStateOf(false)
var pickedMedia by mutableStateOf<SelectedMedia?>(null)
private set
var isWorking by mutableStateOf(false)
/** Set once the user edits any field, so late-arriving metadata won't clobber their input. */
var touched by mutableStateOf(false)
private set
val canPost by derivedStateOf { !isWorking && name.value.text.isNotBlank() }
fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank()
fun initCreate(
accountViewModel: AccountViewModel,
relay: NormalizedRelayUrl,
) {
this.account = accountViewModel.account
if (this.relay == null) {
this.relay = relay
// Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada).
this.groupId = RandomInstance.bytes(8).toHexKey()
}
}
fun initEdit(
accountViewModel: AccountViewModel,
channel: RelayGroupChannel,
) {
this.account = accountViewModel.account
this.channel = channel
this.relay = channel.groupId.relayUrl
this.groupId = channel.groupId.id
}
/** Seed the fields from the group's current relay-signed metadata, unless the user has edited. */
fun prefillFrom(channel: RelayGroupChannel) {
if (touched) return
val event = channel.event
name.value = TextFieldValue(event?.name() ?: "")
about.value = TextFieldValue(event?.about() ?: "")
picture.value = TextFieldValue(event?.picture() ?: "")
isPrivate = channel.isPrivate()
isClosed = channel.isClosed()
isHidden = event?.isHidden() ?: false
isRestricted = event?.isRestricted() ?: false
topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "")
// Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one.
geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "")
}
/** Split the topics field into distinct, non-blank, lowercased hashtags (leading `#` dropped). */
private fun parseTopics(): List<String> =
topics.value.text
.split(',', ' ', '\n', '\t')
.map { it.trim().removePrefix("#").lowercase() }
.filter { it.isNotBlank() }
.distinct()
/** The single geohash (lowercased, `#` stripped), or empty when none. */
private fun parseGeohashes(): List<String> =
geohash.value.text
.trim()
.removePrefix("#")
.lowercase()
.ifBlank { null }
?.let { listOf(it) }
?: emptyList()
fun markTouched() {
touched = true
}
fun pickMedia(media: SelectedMedia) {
pickedMedia = media
touched = true
}
fun submit(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
if (isWorking) return
viewModelScope.launch(Dispatchers.IO) {
isWorking = true
try {
val local = pickedMedia
if (local != null) {
val uploadedUrl = uploadImage(local, context, onError) ?: return@launch
picture.value = TextFieldValue(uploadedUrl)
pickedMedia = null
}
try {
publish()
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
return@launch
}
onSuccess()
} finally {
isWorking = false
}
}
}
private suspend fun publish() {
val name = name.value.text.trim()
val about =
about.value.text
.trim()
.ifBlank { null }
val picture =
picture.value.text
.trim()
.ifBlank { null }
val hashtags = parseTopics()
val geohashes = parseGeohashes()
val existing = channel
if (existing == null) {
account.createRelayGroup(
relay = relay!!,
groupId = groupId,
name = name,
about = about,
picture = picture,
isPrivate = isPrivate,
isClosed = isClosed,
isHidden = isHidden,
isRestricted = isRestricted,
hashtags = hashtags,
geohashes = geohashes,
)
} else {
account.editRelayGroupMetadata(
channel = existing,
name = name,
about = about,
picture = picture,
isPrivate = isPrivate,
isClosed = isClosed,
isHidden = isHidden,
isRestricted = isRestricted,
hashtags = hashtags,
geohashes = geohashes,
)
}
}
private suspend fun uploadImage(
galleryUri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
): String? {
val sourceUri =
try {
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
stringRes(context, R.string.metadata_strip_failed_title),
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
)
return null
}
result.uri
} else {
galleryUri.uri
}
} catch (e: AvifMetadataNotVerifiableException) {
onError(
stringRes(context, R.string.metadata_strip_failed_title),
stringRes(context, R.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
return null
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
return try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
result.url ?: run {
onError(
stringRes(context, R.string.failed_to_upload_media_no_details),
stringRes(context, R.string.server_did_not_provide_a_url_after_uploading),
)
null
}
} catch (_: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.failed_to_upload_media_no_details),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload),
)
null
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
null
}
}
}
@@ -0,0 +1,98 @@
/*
* 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.relayGroup
import androidx.compose.foundation.clickable
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.material3.MaterialTheme
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.font.FontWeight
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.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/** One relay row: NIP-11 avatar + name, used by the browse screen. */
@Composable
fun RelayGroupServerRow(
relayUrl: String,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
val host = relay?.displayUrl() ?: relayUrl
val info = relay?.let { loadRelayInfo(it) }
val name = info?.value?.name?.takeIf { it.isNotBlank() } ?: host
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RenderRelayIcon(
displayUrl = host,
iconUrl = info?.value?.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
pingInMs = 0,
)
Column(Modifier.weight(1f)) {
Text(
text = name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (name != host) {
Text(
text = host,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
@@ -0,0 +1,236 @@
/*
* 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.relayGroup
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.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
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.relayGroup.datasource.RelayGroupThreadFeedSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
/**
* A group's forum-style threads (kind 11) — the secondary content type kept out of
* the kind-9 chat feed. Streams the group's threads + their comments via
* [RelayGroupThreadFeedSubscription]; tapping a thread opens the generic thread view
* ([Route.Note]) with its comment tree. Members can start a new thread.
*/
@Composable
fun RelayGroupThreadsScreen(
id: HexKey,
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val channelId = remember(id, relay) { GroupId(id, relay) }
LoadRelayGroupChannel(channelId, accountViewModel) { channel ->
RelayGroupThreads(channel, accountViewModel, nav)
}
}
@Composable
private fun RelayGroupThreads(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayGroupThreadFeedSubscription(channel, accountViewModel.dataSources().relayGroupThreadFeed, accountViewModel)
val threads by channel.threads.collectAsStateWithLifecycle()
// Only members can post a thread (the relay rejects a non-member's kind-11), so the
// compose FAB is hidden for everyone else.
val canPost = channel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember()
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = {
Column {
Text(
text = stringRes(R.string.relay_group_threads_title),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = channel.toBestDisplayName(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
popBack = nav::popBack,
)
},
floatingActionButton = {
if (canPost) {
FloatingActionButton(
onClick = {
nav.nav(
Route.NewShortNote(
groupThreadId = channel.groupId.id,
groupThreadRelayUrl = channel.groupId.relayUrl.url,
),
)
},
shape = CircleShape,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.relay_group_thread_new),
modifier = Modifier.size(24.dp),
)
}
}
},
) { padding ->
if (threads.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
text = stringRes(R.string.relay_group_threads_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(32.dp),
)
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
itemsIndexed(threads, key = { _, thread -> thread.idHex }) { index, thread ->
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) }
}
}
}
}
}
@Composable
private fun ThreadRow(
thread: Note,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
// Observe the reply count so a kind-1111 comment arriving on an already-listed thread
// bumps it live (channel.threads only re-emits on add/remove of a thread).
val replyCount by observeNoteReplyCount(thread, accountViewModel)
val event = thread.event as? ThreadEvent
val title = event?.title()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.relay_group_thread_untitled)
val preview =
event
?.content
?.replace('\n', ' ')
?.trim()
.orEmpty()
val author = thread.author
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (author != null) {
UserPicture(author, Size35dp, accountViewModel = accountViewModel, nav = nav)
}
Column(Modifier.weight(1f)) {
Text(
text = title,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (preview.isNotEmpty()) {
Text(
text = preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (author != null) {
UsernameDisplay(author, accountViewModel = accountViewModel)
}
Icon(
symbol = MaterialSymbols.Chat,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(13.dp),
)
Text(
text = "$replyCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,311 @@
/*
* 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.relayGroup
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
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
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
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.nip01Core.relay.normalizer.displayUrl
@Composable
fun RelayGroupTopBar(
baseChannel: RelayGroupChannel,
inviteCode: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Recompose when the relay-signed metadata / roster changes.
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val myPubkey = accountViewModel.userProfile().pubkeyHex
val membership = channel.membershipOf(myPubkey)
val memberCount = channel.memberCount()
// Optimistic "requested" state: set on Join, cleared once the relay's roster
// shows us as a member. Never persisted — a fresh visit reads the relay truth.
var requested by remember(channel.groupId) { mutableStateOf(false) }
LaunchedEffect(membership) {
if (membership.isMember()) requested = false
}
// Arrived via an invite link carrying a join code (`wss://relay'id?code=…`): the
// user already opted in by tapping it, so fire the kind-9021 join once (with the
// code) instead of making them re-enter it. Guarded to exactly once and skipped if
// the roster already shows us as a member.
var autoJoined by remember(channel.groupId) { mutableStateOf(false) }
LaunchedEffect(channel.groupId, inviteCode, membership) {
if (inviteCode != null && !autoJoined && !membership.isMember()) {
autoJoined = true
requested = true
accountViewModel.joinRelayGroup(channel, inviteCode)
}
}
val displayMembership = if (!membership.isMember() && requested) RelayGroupMembership.PENDING else membership
var menuOpen by remember { mutableStateOf(false) }
var showInvite by remember { mutableStateOf(false) }
var showJoinCode by remember { mutableStateOf(false) }
TopBarExtensibleWithBackButton(
title = {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
RoleBadge(displayMembership)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (channel.isPrivate()) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(12.dp),
)
}
Text(
text = channel.groupId.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (memberCount > 0) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(12.dp),
)
Text(
text = "$memberCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
actions = {
IconButton(onClick = {
nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url))
}) {
Icon(
symbol = MaterialSymbols.Forum,
contentDescription = stringRes(R.string.relay_group_threads_title),
modifier = Modifier.size(20.dp),
)
}
val naddr = channel.toNAddr()
if (naddr != null) {
val context = LocalContext.current
IconButton(onClick = { shareRelayGroup(context, naddr) }) {
Icon(
symbol = MaterialSymbols.Share,
contentDescription = stringRes(R.string.quick_action_share),
modifier = Modifier.size(20.dp),
)
}
}
when {
displayMembership == RelayGroupMembership.PENDING -> {
Text(
text = stringRes(R.string.relay_group_pending),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
!displayMembership.isMember() -> {
FilledTonalButton(onClick = {
// Closed groups need an invite code; open groups join directly.
if (channel.isClosed()) {
showJoinCode = true
} else {
requested = true
accountViewModel.joinRelayGroup(channel)
}
}) {
Text(stringRes(R.string.join))
}
}
else -> {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
modifier = Modifier.size(22.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_menu_members)) },
onClick = {
menuOpen = false
nav.nav(Route.RelayGroupMembers(channel.groupId.id, channel.groupId.relayUrl.url))
},
)
if (displayMembership == RelayGroupMembership.ADMIN) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_menu_edit)) },
onClick = {
menuOpen = false
nav.nav(Route.RelayGroupEdit(channel.groupId.id, channel.groupId.relayUrl.url))
},
)
}
if (displayMembership.canModerate()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_invite_title)) },
onClick = {
menuOpen = false
showInvite = true
},
)
}
DropdownMenuItem(
text = { Text(stringRes(R.string.leave)) },
onClick = {
menuOpen = false
accountViewModel.leaveRelayGroup(channel)
},
)
}
}
}
},
popBack = nav::popBack,
)
if (showInvite) {
InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false }
}
if (showJoinCode) {
JoinRelayGroupDialog(
channel = channel,
accountViewModel = accountViewModel,
onJoined = { requested = true },
onDismiss = { showJoinCode = false },
)
}
}
/** Fire the system share sheet with a njump web link to the group's naddr. */
private fun shareRelayGroup(
context: Context,
naddr: String,
) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, njumpLink(naddr))
putExtra(Intent.EXTRA_TITLE, stringRes(context, R.string.quick_action_share_browser_link))
}
context.startActivity(Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)))
}
/** A small colored pill naming the user's role/status in the group. */
@Composable
private fun RoleBadge(membership: RelayGroupMembership) {
val label =
when (membership) {
RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin)
RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator)
RelayGroupMembership.PENDING -> stringRes(R.string.relay_group_pending)
// A plain member needs no badge; the lack of a Join button already says it.
RelayGroupMembership.MEMBER, RelayGroupMembership.NONE -> return
}
val container =
if (membership == RelayGroupMembership.ADMIN || membership == RelayGroupMembership.MODERATOR) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.secondaryContainer
}
val content =
if (membership == RelayGroupMembership.ADMIN || membership == RelayGroupMembership.MODERATOR) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSecondaryContainer
}
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),
)
}
}
@@ -0,0 +1,44 @@
/*
* 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.relayGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* Eagerly warms the NIP-11 documents of the host relays of every group the user has joined, in
* parallel. The NIP-29 relay-signed check ([com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup])
* reads those docs from cache, so pre-warming them from a primary tab (Messages) means the moment
* one of these groups also surfaces in discovery — or any relay-signed gate runs — the answer is a
* cache hit rather than a cold fetch. Cheap: the cache dedups and holds each doc for an hour.
*/
@Composable
fun WarmJoinedRelayGroupNip11(accountViewModel: AccountViewModel) {
val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers
.collectAsStateWithLifecycle()
val relays = remember(servers) { servers.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } }
WarmNip11(relays)
}
@@ -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.screen.loggedIn.chats.publicChannels.relayGroup.dal
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint] (the shared,
* platform-agnostic matcher lives in `:commons`). The relays are exactly the relays the filter
* routes to; each relay's constraint carries only that relay's slice of the follow/topic/geo set
* (matching how the note feeds shard per relay). Community selections have no people/topic/geo
* dimension, so they fall back to [GroupDiscoveryConstraint.AllGroups] — still constraining the
* RELAY set, just not the people. Each branch mirrors what the datasource actually REQs for that
* filter, so fetch and display agree.
*/
fun IFeedTopNavPerRelayFilterSet.toGroupConstraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> =
when (this) {
is GlobalTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
is RelayTopNavPerRelayFilterSet -> mapOf(relayUrl to GroupDiscoveryConstraint.AllGroups)
is AuthorsTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) }
is HashtagTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByHashtags(f.hashtags) }
is LocationTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByGeohashes(f.geotags) }
is AllFollowsTopNavPerRelayFilterSet ->
set.mapValues { (_, f) ->
val lenses =
buildList {
f.authors?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByPeople(it)) }
f.hashtags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByHashtags(it)) }
f.geotags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByGeohashes(it)) }
}
if (lenses.isEmpty()) GroupDiscoveryConstraint.AllGroups else GroupDiscoveryConstraint.AnyOf(lenses)
}
// Muted-authors is a "show me the muted" lens: keep groups where a muted user is the relay
// key / an admin / a member (matching the #p roster REQ that fetches them).
is MutedAuthorsTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) }
// Community selections carry no group-hosting dimension; show every group the resolved
// relays host (matching the broad directory REQ). Empty for DVM/anything with no relays.
is AllCommunitiesTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
is SingleCommunityTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
else -> emptyMap()
}
@@ -0,0 +1,189 @@
/*
* 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.relayGroup.dal
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* Resolves a relay-signed kind-39000 metadata note to the live [RelayGroupChannel] it belongs to.
* A group is keyed by (host relay + group id); the SAME resolver is used by the feed filter's
* match/sort AND the discovery row's renderer, so a note seen on more than one relay always binds
* to one channel (no more "sorted by relay B's roster, rendered with relay A's empty one").
*/
fun relayGroupDiscoveryChannelFor(note: Note): RelayGroupChannel? {
val groupId = (note.event as? GroupMetadataEvent)?.groupId() ?: return null
return note.relays.firstNotNullOfOrNull { LocalCache.getRelayGroupChannelIfExists(GroupId(groupId, it)) }
}
/**
* The Relay Groups discovery feed. Rows are the relay-signed kind-39000 metadata notes; the
* top-nav filter is resolved into a per-relay [GroupDiscoveryConstraint] ([toGroupConstraints])
* that decides which groups qualify: every group for Global, relay-key / admin / member for the
* people filters (rosters read from the group's [RelayGroupChannel]), or a `#t`/`#g` tag match
* for topic/geo filters. Structurally identical to
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal.GitRepositoriesFeedFilter];
* the only difference is that a group's author is its relay, so the match runs through the
* roster-aware constraint rather than the author-based [com.vitorpamplona.amethyst.ui.dal.FilterByListParams].
*/
class RelayGroupDiscoveryFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
/**
* The feed identity is the selected list PLUS a fingerprint of what the feed actually filters
* on. The list flips synchronously when the spinner changes, but the per-relay set it resolves
* to ([liveRelayGroupsDiscoveryFollowListsPerRelay]) lands a frame later (the outbox loader is
* async). If the key were the list alone, the first refresh would run against the STALE set
* (showing the old filter's groups) and the catch-up emission — same list code — would be
* swallowed by [checkKeysInvalidateDataAndSendToTop]'s "key unchanged" guard, freezing the feed
* on the previous filter until a manual pull-to-refresh. Folding the resolved discriminator in
* (the joined ids for "My Groups", the per-relay constraints otherwise — both content-hashed via
* data classes) makes the key move when the resolution lands, so the refresh actually fires.
*/
override fun feedKey(): String =
account.userProfile().pubkeyHex + "-" + followList().code + "-" +
(if (isMine()) joinedGroupIds().hashCode() else constraints().hashCode())
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value
/**
* "My Groups" = every group I'm participating in. That's the UNION of:
* - the groups I explicitly joined (my kind-10009 list) — authoritative and immediate, even
* before the relay adds me to its roster or for open groups it doesn't roster; and
* - any group whose relay-signed roster (39001/39002) lists me as an admin/member (e.g. an
* admin added me, or I was rostered on a relay I haven't listed in kind 10009).
*
* These live on their host relays (from kind 10009), not my outbox, so the standard
* [TopFilter.Mine] relay-set resolution wouldn't reach them. The joined groups' metadata +
* rosters are kept in cache by RelayGroupMyJoinedGroupsSubscription mounted on the screen.
*/
private fun isMine(): Boolean = followList() is TopFilter.Mine
/** The (relay + id) of every group in my kind-10009 joined list. */
private fun joinedGroupIds(): Set<GroupId> =
account.relayGroupList.liveRelayGroupList.value
.mapNotNullTo(HashSet()) { tag ->
RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { GroupId(tag.groupId, it) }
}
private fun isMyGroup(
channel: RelayGroupChannel,
joined: Set<GroupId>,
): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember()
private fun constraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints()
override fun feed(): List<Note> {
if (isMine()) return sort(myGroupNotes())
val byRelay = constraints()
val notes =
LocalCache.addressables.filterIntoSet(GroupMetadataEvent.KIND) { _, note ->
matches(note, byRelay)
}
return sort(notes)
}
private fun myGroupNotes(): Set<Note> {
val joined = joinedGroupIds()
val notes = HashSet<Note>()
// Groups I explicitly joined — resolve each to its metadata note (may be null until loaded).
joined.forEach { LocalCache.getRelayGroupChannelIfExists(it)?.metadataNote?.let(notes::add) }
// Plus any group whose roster lists me as an admin/member.
val me = account.userProfile().pubkeyHex
LocalCache.relayGroupChannels
.filter { _, channel -> channel.event != null && channel.membershipOf(me).isMember() }
.forEach { it.metadataNote?.let(notes::add) }
return notes
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val byRelay = constraints()
return newItems.flatMapTo(HashSet()) { note ->
when (note.event) {
is GroupMetadataEvent -> if (matches(note, byRelay)) listOf(note) else emptyList()
// A roster change (39001/39002) can newly qualify a group whose 39000 already
// arrived (a follow just became an admin/member). The roster note itself isn't a
// feed row, so re-test the group's metadata note and inject it if it now matches —
// otherwise the group would stay hidden (or frozen at 0 members) until a refresh.
is GroupAdminsEvent, is GroupMembersEvent ->
rosterMetadataNote(note)?.takeIf { matches(it, byRelay) }?.let { listOf(it) } ?: emptyList()
else -> emptyList()
}
}
}
/**
* The group qualifies when its host relay is in the filter's set AND that relay's constraint
* accepts it. [relayGroupDiscoveryChannelFor] picks the one channel (metadata + roster); the
* roster lives on the [RelayGroupChannel] [LocalCache.consume] created when the metadata arrived.
*/
private fun matches(
note: Note,
byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>,
): Boolean {
val channel = relayGroupDiscoveryChannelFor(note) ?: return false
if (isMine()) return isMyGroup(channel, joinedGroupIds())
if (byRelay.isEmpty()) return false
// Only surface groups whose 39000 is actually signed by the host relay's key (NIP-29's
// authority). This drops stray user-published 39000s stored on general relays — the ones
// with no roster that can't be joined — even when the relay itself is otherwise queried.
if (!isRelaySignedRelayGroup(channel)) return false
return byRelay[channel.groupId.relayUrl]?.matches(channel) == true
}
/** The 39000 metadata note for the group a roster (39001/39002) note belongs to, if cached. */
private fun rosterMetadataNote(note: Note): Note? {
val groupId =
when (val event = note.event) {
is GroupAdminsEvent -> event.groupId()
is GroupMembersEvent -> event.groupId()
else -> null
} ?: return null
return note.relays
.firstNotNullOfOrNull { LocalCache.getRelayGroupChannelIfExists(GroupId(groupId, it)) }
?.metadataNote
}
override fun sort(items: Set<Note>): List<Note> {
// Snapshot the member count once per note (stable key), then order by it descending with
// the shared default feed order (createdAt snapshot + id) as a stable tie-break. Reading
// createdAt live inside a comparator risks TimSort's "contract violated" crash when a
// newer 39000 replaces a note's event mid-sort — sortedByDefaultFeedOrder avoids that.
val memberCount = items.associateWith { relayGroupDiscoveryChannelFor(it)?.memberCount() ?: 0 }
return items.sortedByDefaultFeedOrder().sortedByDescending { memberCount[it] }
}
}
@@ -0,0 +1,106 @@
/*
* 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.relayGroup.datasource
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
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/** One screen's request to keep the roster of the user's joined groups fresh. */
class RelayGroupMyJoinedGroupsQueryState(
val account: Account,
)
/**
* Roster kinds only (39000 metadata + 39001 admins + 39002 members) — enough to
* resolve name, member count and this user's membership. Roles (39003) are pulled
* by the per-chat / directory subscriptions when actually needed.
*/
private val RELAY_GROUP_ROSTER_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
)
/**
* Keeps the relay-signed roster (metadata/admins/members) of every group the user
* has joined live while a groups-bearing screen is on top, so membership,
* pending→member transitions and member counts stay accurate in list views
* without having to open each chat. This matters most for closed/private groups,
* where the only way to confirm a join was admitted is a fresh 39002.
*
* One subscription per host relay, scoped by `#d` to just that relay's joined
* group ids — so we don't pull a relay's whole directory, only what we're in.
*/
class RelayGroupMyJoinedGroupsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupMyJoinedGroupsQueryState>() {
val group =
listOf(
RelayGroupMyJoinedGroupsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupMyJoinedGroupsSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupMyJoinedGroupsQueryState>,
) : PerUniqueIdEoseManager<RelayGroupMyJoinedGroupsQueryState, Account>(client, allKeys) {
override fun updateFilter(
key: RelayGroupMyJoinedGroupsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) return null
// Group the joined group ids by their host relay: one #d-scoped filter each.
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
return idsByRelay.mapNotNull { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_ROSTER_KINDS,
tags = mapOf("d" to groupIds.distinct()),
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: RelayGroupMyJoinedGroupsQueryState) = key.account
}
@@ -0,0 +1,56 @@
/*
* 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.relayGroup.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 any screen that lists the user's joined groups (the Messages tab's
* inline/grouped views, the Relay Groups home) to keep their rosters live.
*
* The query state is keyed on the account (stable), so the assembler wouldn't
* re-run its filter derivation on its own when the joined-group set changes.
* We watch [liveRelayGroupList] and invalidate the assembler on every change, so
* a join/leave while this screen stays foregrounded immediately re-subscribes to
* the new group's roster (critical for confirming admission to a closed group).
*/
@Composable
fun RelayGroupMyJoinedGroupsSubscription(
dataSource: RelayGroupMyJoinedGroupsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
RelayGroupMyJoinedGroupsQueryState(accountViewModel.account)
}
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
LaunchedEffect(joined) { dataSource.invalidateFilters() }
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -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.publicChannels.relayGroup.datasource
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
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
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
/** One threads-screen's request for a single group's kind-11 threads. */
class RelayGroupThreadFeedQueryState(
val channel: RelayGroupChannel,
)
/**
* The forum content of a NIP-29 group: kind-11 thread roots plus their kind-1111
* comment trees, both scoped by the group's `h` tag and pinned to the host relay.
* Active only while a group's Threads screen is open (threads are secondary to the
* kind-9 chat, so we don't pay for them until asked). Fetching the comments here
* too means opening a thread from the list has its replies already cached.
*/
class RelayGroupThreadFeedFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupThreadFeedQueryState>() {
val group =
listOf(
RelayGroupThreadFeedSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupThreadFeedSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupThreadFeedQueryState>,
) : PerUniqueIdEoseManager<RelayGroupThreadFeedQueryState, GroupId>(client, allKeys) {
override fun updateFilter(
key: RelayGroupThreadFeedQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val groupId = key.channel.groupId
return listOf(
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND),
tags = mapOf("h" to listOf(groupId.id)),
since = since?.get(groupId.relayUrl)?.time,
),
),
)
}
override fun id(key: RelayGroupThreadFeedQueryState) = key.channel.groupId
}
@@ -0,0 +1,42 @@
/*
* 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.relayGroup.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/** Mount on a group's Threads screen to stream its kind-11 threads + 1111 comments. */
@Composable
fun RelayGroupThreadFeedSubscription(
channel: RelayGroupChannel,
dataSource: RelayGroupThreadFeedFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(channel.groupId) {
RelayGroupThreadFeedQueryState(channel)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -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.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.filterMetadataToRelayGroup
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** One on-screen group card's request to warm a single group. */
class RelayGroupWarmupQueryState(
val channel: RelayGroupChannel,
/** When true, prefetch only recent content — the caller's screen already streams metadata. */
val contentOnly: Boolean = false,
/** How many recent content events to prefetch ahead of a tap. */
val contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT,
)
/** Newest content kinds we prefetch so opening the card lands on populated screens. */
private val RELAY_GROUP_WARMUP_CONTENT_KINDS =
listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
/**
* Default number of recent events to pull ahead of a tap — enough to fill the first screen AND drive
* the discovery card's "50+ messages" activity signal (a chat that returns the full page reads as
* "50+"; fewer shows the exact loaded count). Callers that only need a first-screen preview (e.g. a
* relay's channel list) pass a smaller [RelayGroupWarmupQueryState.contentLimit].
*/
const val RELAY_GROUP_WARMUP_LIMIT = 50
/**
* Warms a NIP-29 group referenced inline (a group-link card) without opening it:
* keeps the relay-signed metadata (name/picture/about + admins/members/roles) live
* so the card fills in and stays current, and prefetches the newest handful of chat
* messages and threads so tapping the card lands on already-cached content. Both are
* pinned to the group's single host relay. Active only while the card is on-screen.
*/
class RelayGroupWarmupFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupWarmupQueryState>() {
val group =
listOf(
RelayGroupWarmupSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupWarmupSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupWarmupQueryState>,
) : PerUniqueIdEoseManager<RelayGroupWarmupQueryState, GroupId>(client, allKeys) {
override fun updateFilter(
key: RelayGroupWarmupQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val groupId = key.channel.groupId
val metadata = if (key.contentOnly) emptyList() else filterMetadataToRelayGroup(key.channel, since)
return metadata +
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_WARMUP_CONTENT_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
limit = key.contentLimit,
since = since?.get(groupId.relayUrl)?.time,
),
)
}
override fun id(key: RelayGroupWarmupQueryState) = key.channel.groupId
}
@@ -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.publicChannels.relayGroup.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Mount on a group card to warm its recent content ahead of a tap. [contentOnly] drops the
* metadata/roster filter (39000-39003) — pass true on the discovery feed, where the directory
* subscription already streams that for the whole relay, so each visible row asks for content only.
*/
@Composable
fun RelayGroupWarmupSubscription(
channel: RelayGroupChannel,
dataSource: RelayGroupWarmupFilterAssembler,
accountViewModel: AccountViewModel,
contentOnly: Boolean = false,
contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT,
) {
val state =
remember(channel.groupId, contentOnly, contentLimit) {
RelayGroupWarmupQueryState(channel, contentOnly, contentLimit)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,61 @@
/*
* 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.relayGroup.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByCommunity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByRelay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makeRelayGroupsDiscoveryFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterRelayGroupsByAllCommunities(feedSettings, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterRelayGroupsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterRelayGroupsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterRelayGroupsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterRelayGroupsByHashtag(feedSettings, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterRelayGroupsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterRelayGroupsByMutedAuthors(feedSettings, since, defaultSince)
is RelayTopNavPerRelayFilterSet -> filterRelayGroupsByRelay(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterRelayGroupsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,50 @@
/*
* 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.relayGroup.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class RelayGroupsDiscoveryQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class RelayGroupsDiscoveryFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupsDiscoveryQueryState>() {
val group =
listOf(
RelayGroupsDiscoverySubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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.publicChannels.relayGroup.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
RelayGroupsDiscoveryFilterAssemblerSubscription(
accountViewModel.dataSources().relayGroupsDiscovery,
accountViewModel,
)
}
@Composable
fun RelayGroupsDiscoveryFilterAssemblerSubscription(
dataSource: RelayGroupsDiscoveryFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
RelayGroupsDiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,172 @@
/*
* 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.relayGroup.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.relayGroupChannelsByRelay
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class RelayGroupsDiscoverySubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupsDiscoveryQueryState>,
) : PerUserAndFollowListEoseManager<RelayGroupsDiscoveryQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: RelayGroupsDiscoveryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
val defaultSince = key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtIfFilled()
val base = makeRelayGroupsDiscoveryFilter(feedSettings, since, defaultSince)
// The follow-list filter sets resolve their relays via the outbox model (a follow's own
// publish relays), but a NIP-29 roster (39001/39002) lives ONLY on the group's host relay.
// So a follow who is an admin/member of a group never surfaces unless we also ask that host
// relay for `#p:<follows>` rosters. Without this, All-Follows shows nothing until the user
// bounces through Global (which pulls the whole directory) — the reported bug.
val (follows, alreadyQueried) =
when (feedSettings) {
is AllFollowsTopNavPerRelayFilterSet ->
feedSettings.set.values.flatMapTo(HashSet<HexKey>()) { it.authors.orEmpty() } to feedSettings.set.keys
is AuthorsTopNavPerRelayFilterSet ->
feedSettings.set.values.flatMapTo(HashSet<HexKey>()) { it.authors } to feedSettings.set.keys
else -> return base
}
if (follows.isEmpty()) return base
// Query the follows as `#p` against every group-host relay we already know about (joined via
// kind-10009 + favorited via kind-10012), minus the relays the outbox filter already covers.
val hostRelays = key.groupHostRelays() - alreadyQueried
if (hostRelays.isEmpty()) return base
val channelsByRelay = relayGroupChannelsByRelay()
val extra =
hostRelays.flatMap { relay ->
filterRelayGroupsByAuthors(
relay = relay,
authors = follows,
since = since?.get(relay)?.time ?: defaultSince,
cachedChannels = channelsByRelay[relay].orEmpty(),
)
}
return base + extra
}
/** Relays that host NIP-29 groups the user is connected to: joined (kind-10009) + favorited (kind-10012). */
private fun RelayGroupsDiscoveryQueryState.groupHostRelays(): Set<NormalizedRelayUrl> =
buildSet {
account.relayGroupList.liveRelayGroupServers.value.forEach { server ->
RelayUrlNormalizer.normalizeOrNull(server)?.let(::add)
}
addAll(account.relayFeedsList.flow.value)
}
override fun user(key: RelayGroupsDiscoveryQueryState) = key.account.userProfile()
override fun list(key: RelayGroupsDiscoveryQueryState) = key.listName()
fun RelayGroupsDiscoveryQueryState.listNameFlow() = account.settings.defaultRelayGroupsDiscoveryFollowList
fun RelayGroupsDiscoveryQueryState.listName() = listNameFlow().value
fun RelayGroupsDiscoveryQueryState.followsPerRelayFlow() = account.liveRelayGroupsDiscoveryFollowListsPerRelay
fun RelayGroupsDiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: RelayGroupsDiscoveryQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
// A #p roster hit (39001/39002) discovers a group but carries no 39000, so re-run
// the assembly when rosters land — that fires the #d metadata backfill for groups
// where a follow is an admin/member but whose metadata isn't cached yet.
key.account.scope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.sample(2000).collectLatest { bundle ->
if (bundle.any { it.event is GroupAdminsEvent || it.event is GroupMembersEvent }) {
invalidateFilters()
}
}
},
// Joining/leaving a group or favoriting a relay changes the host-relay set we probe
// for follow rosters above, so re-assemble when either list moves.
key.account.scope.launch(Dispatchers.IO) {
key.account.relayGroupList.liveRelayGroupServers.sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.account.relayFeedsList.flow.sample(500).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,93 @@
/*
* 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.relayGroup.datasource
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
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
/** One screen's request for the full channel directory of a single relay. */
class RelayGroupsOnRelayQueryState(
val relay: NormalizedRelayUrl,
val account: Account,
)
private val RELAY_GROUP_DIRECTORY_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
)
/**
* Subscribes to the relay-signed directory (kinds 39000-39003) of a single relay,
* so the "browse a relay's channels" screen sees every group the relay hosts. The
* relay signs these with its own key; a single-relay, unscoped-by-`d` query pulls
* the whole directory. Consumed into per-group [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]s
* keyed by (relay + group id).
*/
class RelayGroupsOnRelayFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupsOnRelayQueryState>() {
val group =
listOf(
RelayGroupsOnRelaySubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupsOnRelaySubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupsOnRelayQueryState>,
) : PerUniqueIdEoseManager<RelayGroupsOnRelayQueryState, NormalizedRelayUrl>(client, allKeys) {
override fun updateFilter(
key: RelayGroupsOnRelayQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = key.relay,
filter =
Filter(
kinds = RELAY_GROUP_DIRECTORY_KINDS,
limit = 500,
since = since?.get(key.relay)?.time,
),
),
)
override fun id(key: RelayGroupsOnRelayQueryState) = key.relay
}
@@ -0,0 +1,41 @@
/*
* 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.relayGroup.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable
fun RelayGroupsOnRelaySubscription(
relay: NormalizedRelayUrl,
dataSource: RelayGroupsOnRelayFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account, relay) {
RelayGroupsOnRelayQueryState(relay, accountViewModel.account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -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.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/** One scan of the group cache, grouped by host relay — reused across every relay in a filter set. */
fun relayGroupChannelsByRelay(): Map<NormalizedRelayUrl, List<RelayGroupChannel>> =
LocalCache.relayGroupChannels
.filter { _, _ -> true }
.groupBy { it.groupId.relayUrl }
/**
* A group's kind-39000 is relay-signed, so a follow shows up in three distinct ways, and each is a
* real relay-side REQ (not a broad "pull everything" pass):
* 1. the follow IS the relay signing key → `{kinds:[39000], authors:<follows>}` (metadata included);
* 2. a follow is an admin/member → `{kinds:[39001,39002], #p:<follows>}` (standard p-tag filter);
* 3. metadata backfill for the groups (2) surfaced → `{kinds:[39000], #d:<their group-ids>}`, read
* from the rosters already in [cachedChannels] (empty on the first pass, filled once (2)'s events
* land and the sub-assembler re-invalidates).
*
* [cachedChannels] is this relay's slice of [relayGroupChannelsByRelay]; the set-level callers scan
* the cache once and pass the slice so the (3) backfill isn't an O(relays × total-groups) re-scan.
*/
fun filterRelayGroupsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
cachedChannels: List<RelayGroupChannel> = LocalCache.getRelayGroupChannelsOnRelay(relay),
): List<RelayBasedFilter> {
if (authors.isEmpty()) return emptyList()
val authorList = authors.sorted()
val filters =
mutableListOf(
// (1) groups whose relay signing-key is a follow
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = listOf(GroupMetadataEvent.KIND),
limit = 200,
since = since,
),
),
// (2) groups where a follow is an admin (39001) or member (39002)
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND),
tags = mapOf(PTag.TAG_NAME to authorList),
limit = 200,
since = since,
),
),
)
// (3) backfill 39000 metadata for the roster-discovered groups already known on this relay.
val rosterGroupIds =
cachedChannels
.filter { channel -> channel.admins.any { it.pubKey in authors } || channel.members.any { it in authors } }
.map { it.groupId.id }
if (rosterGroupIds.isNotEmpty()) {
filters +=
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(DTag.TAG_NAME to rosterGroupIds),
limit = 200,
since = since,
),
)
}
return filters
}
fun filterRelayGroupsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
val channelsByRelay = relayGroupChannelsByRelay()
return authorSet.set.flatMap {
filterRelayGroupsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
cachedChannels = channelsByRelay[it.key].orEmpty(),
)
}
}
fun filterRelayGroupsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
val channelsByRelay = relayGroupChannelsByRelay()
return authorSet.set.flatMap {
filterRelayGroupsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
cachedChannels = channelsByRelay[it.key].orEmpty(),
)
}
}
@@ -0,0 +1,59 @@
/*
* 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.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
// A community selection has no group-hosting dimension of its own; it constrains only the RELAY
// set, so the directory is pulled per relay and every group those relays host is shown.
fun filterRelayGroupsByAllCommunities(
communitySet: AllCommunitiesTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
fun filterRelayGroupsByCommunity(
communitySet: SingleCommunityTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
@@ -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.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
// The All-Follows filter is a big-OR: a group surfaces if a follow is its relay key / admin /
// member (via [filterRelayGroupsByAuthors]) OR it carries a followed topic / geohash. The local
// AnyOf constraint matches all three lenses, so the REQ must fetch all three — otherwise a group
// under a followed hashtag run by non-followed people would be matched but never requested.
fun filterRelayGroupsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
val channelsByRelay = relayGroupChannelsByRelay()
return followsSet.set.flatMap {
val relay = it.key
val relaySince = since?.get(relay)?.time ?: defaultSince
buildList {
it.value.authors?.takeIf { a -> a.isNotEmpty() }?.let { authors ->
addAll(filterRelayGroupsByAuthors(relay, authors, relaySince, channelsByRelay[relay].orEmpty()))
}
it.value.hashtags?.takeIf { h -> h.isNotEmpty() }?.let { hashtags ->
addAll(filterRelayGroupsByHashtag(relay, hashtags, relaySince))
}
it.value.geotags?.takeIf { g -> g.isNotEmpty() }?.let { geotags ->
addAll(filterRelayGroupsByGeohashes(relay, geotags, relaySince))
}
}
}
}
@@ -0,0 +1,71 @@
/*
* 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.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
fun filterRelayGroupsByGeohashes(
relay: NormalizedRelayUrl,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(GeoHashTag.TAG_NAME to geotags.map { it.lowercase() }.sorted()),
limit = 100,
since = since,
),
),
)
}
fun filterRelayGroupsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterRelayGroupsByGeohashes(
relay = it.key,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,68 @@
/*
* 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.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
fun filterRelayGroupsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(HashtagTag.TAG_NAME to hashtags.map { it.lowercase() }),
limit = 200,
since = since,
),
),
)
fun filterRelayGroupsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterRelayGroupsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -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.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
/**
* Browse every group hosted on a single favorite relay (the relay chip in the discovery spinner).
* Same whole-directory pull as [filterRelayGroupsGlobal], scoped to the one relay the chip names —
* matching the dal's [RelayTopNavPerRelayFilterSet] → `AllGroups` on that relay constraint.
*/
fun filterRelayGroupsByRelay(
relays: RelayTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
filterRelayGroupsDirectory(
relay = relays.relayUrl,
since = since?.get(relays.relayUrl)?.time ?: defaultSince,
)
@@ -0,0 +1,60 @@
/*
* 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.relayGroup.datasource.subassemblies
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.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* A group's kind-39000 is relay-signed, so — unlike git repositories, whose author IS a follow —
* the people dimension (relay-key follow / follow-is-admin / follow-is-member) can't be expressed
* as an `authors` REQ. For Global and the people filters we therefore pull the whole directory
* (metadata + rosters) for each relay in the resolved set and let
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.RelayGroupDiscoveryFeedFilter]
* narrow it locally. Only the topic/geo filters ([filterRelayGroupsByHashtag]/[filterRelayGroupsByGeohashes])
* carry a real relay-side constraint.
*/
val RELAY_GROUP_DISCOVERY_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
)
fun filterRelayGroupsDirectory(
relay: NormalizedRelayUrl,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_DISCOVERY_KINDS,
limit = 500,
since = since,
),
),
)
@@ -0,0 +1,43 @@
/*
* 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.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterRelayGroupsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
// No `since` floor: a group's kind-39000 is stable metadata that a relay rarely re-signs, so a
// "last week" cutoff (as the git-repos feed uses) would hide long-lived groups. We page on the
// whole directory instead, carrying only the EOSE cursor once we have one.
return relays.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
@@ -76,6 +77,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
@@ -84,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip28PublicChat.base.notify
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
@@ -98,6 +101,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
@@ -543,6 +547,45 @@ open class ChannelNewMessageViewModel :
}
}
channel is RelayGroupChannel -> {
// NIP-29 group message: a kind-9 chat scoped to the group with an
// `h` tag. The event is published only to the group's host relay
// (channel.relays()), where relay29 authorizes and routes it.
val replyingToEvent = replyTo.value?.toEventHint<ChatEvent>()
if (replyingToEvent != null) {
// A reply quotes its parent via a NIP-18 `q` tag (added by ChatEvent.reply)
// + a `p` notify to its author. The group scope (`h`) alone doesn't link a
// reply to what it answers — without the `q` tag the quote renders in the
// composer but is missing from the signed event.
ChatEvent.reply(tagger.message, replyingToEvent) {
hTag(channel.groupId.id)
pTag(replyingToEvent.toPTag())
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
} else {
ChatEvent.build(tagger.message) {
hTag(channel.groupId.id)
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
}
else -> {
null
}
@@ -121,6 +121,25 @@ fun ChannelFabColumn(nav: INav) {
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
nav.nav(Route.RelayGroupBrowse)
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.relay_group_browse_title),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
}
@@ -20,12 +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
@@ -33,6 +41,7 @@ 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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -40,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -47,6 +57,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
@@ -70,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
@@ -80,11 +93,15 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@Composable
@@ -93,11 +110,18 @@ fun ChatroomHeaderCompose(
accountViewModel: AccountViewModel,
nav: INav,
) {
val isEmptyMarmotPlaceholder =
baseNote.event == null &&
baseNote.inGatherers?.any { it is MarmotGroupChatroom } == true
// Some Messages rows have no event: a per-relay grouped row (RelayGroupServerRoomNote), or a
// joined Marmot/NIP-29 group with no messages yet (an event-less placeholder carrying its channel
// as a gatherer). Render these directly instead of waiting for an event that never arrives, which
// would blank the row.
val rendersWithoutEvent =
baseNote is RelayGroupServerRoomNote ||
(
baseNote.event == null &&
baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == true
)
if (baseNote.event != null || isEmptyMarmotPlaceholder) {
if (baseNote.event != null || rendersWithoutEvent) {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
} else {
val hasEvent by observeNoteHasEvent(baseNote, accountViewModel)
@@ -132,12 +156,41 @@ private fun ChatroomEntry(
accountViewModel: AccountViewModel,
nav: INav,
) {
if (lastMessage is RelayGroupServerRoomNote) {
RelayGroupServerRoomCompose(lastMessage, accountViewModel, nav)
return
}
val marmotGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
if (marmotGroup != null) {
MarmotGroupRoomCompose(lastMessage, marmotGroup, accountViewModel, nav)
return
}
val relayGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }
if (relayGroup != null) {
RelayGroupRoomCompose(lastMessage, relayGroup, 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.
val groupScopedEvent = lastMessage.event?.takeIf { it.isGroupScoped() }
if (groupScopedEvent != null) {
val gid = groupScopedEvent.groupId()
val hostRelay = lastMessage.relays.firstOrNull()
if (gid != null && hostRelay != null) {
RelayGroupRoomCompose(
lastMessage,
LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)),
accountViewModel,
nav,
)
return
}
}
val baseNoteEvent = lastMessage.event
when (baseNoteEvent) {
// ChannelMessageEvent and ChannelMetadataEvent both expose channelId() via
@@ -299,6 +352,131 @@ private fun MarmotGroupRoomCompose(
)
}
@Composable
private fun RelayGroupRoomCompose(
lastMessage: Note,
baseChannel: RelayGroupChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: 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 group with no messages yet — say so
// explicitly (like Marmot groups) instead of an empty second line.
stringRes(R.string.relay_group_no_messages_yet)
}
ChannelName(
channelIdHex = channel.groupId.id,
channelPicture = channel.profilePicture(),
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),
)
Spacer(Modifier.width(6.dp))
RelayNameChip(
label = channel.groupId.relayUrl.displayUrl(),
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
)
}
},
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.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) },
)
}
@Composable
private fun RelayGroupServerRoomCompose(
row: RelayGroupServerRoomNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = row.relay
val relayInfo by loadRelayInfo(relay)
val host = relay.displayUrl()
val name = relayInfo.name?.takeIf { it.isNotBlank() } ?: host
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 = relay.url,
channelPicture = relayInfo.icon,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.relay_group_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.RelayGroupServer(relay.url)) },
)
}
/** A small tappable chip naming the relay a channel is hosted on. */
@Composable
private fun RelayNameChip(
label: String,
onClick: () -> Unit,
) {
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
private fun ChannelTitleWithLabelInfo(
channelName: String,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.util.replace
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -29,11 +31,17 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupChatContent
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
class ChatroomListKnownFeedFilter(
val account: Account,
@@ -89,7 +97,38 @@ class ChatroomListKnownFeedFilter(
}
}
return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups).toSet())
// NIP-29 relay groups the user joined (kind 10009). In INLINE view mode each group is its
// own row tagged with its host relay; in GROUPED mode all the groups on one relay collapse
// to a single relay row positioned by that relay's newest message. Both interleave with the
// rest of the Messages list by recency.
val relayGroups =
when (account.settings.relayGroupViewMode.value) {
RelayGroupViewMode.INLINE ->
account.relayGroupList.liveRelayGroupList.value.mapNotNull { groupTag ->
val relay = RelayUrlNormalizer.normalizeOrNull(groupTag.relayUrl) ?: return@mapNotNull null
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay))
// Newest loaded chat message, or a placeholder row so a just-joined group shows
// up on Messages before its first kind-9 arrives (mirrors the Marmot-group path
// above). Content kinds only — never a reaction/deletion as the "last message".
channel.newestChatNote(account) ?: channel.placeholderNote()
}
RelayGroupViewMode.GROUPED ->
// One row per host relay (never duplicated), carrying the newest chat across ALL of
// that relay's joined groups so it lands in the newest-message spot among the DMs.
account.relayGroupList.liveRelayGroupList.value
.groupBy { it.relayUrl }
.mapNotNull { (relayUrl, tags) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
val newest =
tags
.mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) }
.maxByOrNull { it.createdAt() ?: 0L }
RelayGroupServerRoomNote(relay, newest)
}
}
return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups).toSet())
}
override fun updateListWith(
@@ -104,8 +143,13 @@ class ChatroomListKnownFeedFilter(
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) {
if (newRelevantPrivateMessages.isEmpty() &&
newRelevantPublicMessages.isEmpty() &&
newRelevantEphemeralChats.isEmpty() &&
newRelevantRelayGroups.isEmpty()
) {
return oldList
}
@@ -160,6 +204,21 @@ class ChatroomListKnownFeedFilter(
}
}
newRelevantRelayGroups.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
if (newNotePair.key == oldNote.relayGroupRowKey()) {
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)
}
@@ -170,11 +229,21 @@ class ChatroomListKnownFeedFilter(
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) {
return if (newRelevantPrivateMessages.isEmpty() &&
newRelevantPublicMessages.isEmpty() &&
newRelevantEphemeralChats.isEmpty() &&
newRelevantRelayGroups.isEmpty()
) {
emptySet()
} else {
(newRelevantPrivateMessages.values + newRelevantPublicMessages.values + newRelevantEphemeralChats.values).toSet()
(
newRelevantPrivateMessages.values +
newRelevantPublicMessages.values +
newRelevantEphemeralChats.values +
newRelevantRelayGroups.values
).toSet()
}
}
@@ -227,6 +296,78 @@ class ChatroomListKnownFeedFilter(
return newRelevantEphemeralChats
}
/** The newest actual chat message loaded in this group's channel, or null if none yet. */
private fun RelayGroupChannel.newestChatNote(account: Account): Note? =
notes
.filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true }
.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
* (INLINE), or an empty-group placeholder resolved back to its group id via its channel gatherer.
*/
private fun Note.relayGroupRowKey(): String? =
when (this) {
is RelayGroupServerRoomNote -> relay.url
else ->
event?.takeIf { it.isGroupScoped() }?.groupId()
?: inGatherers?.firstNotNullOfOrNull { (it as? RelayGroupChannel)?.groupId?.id }
}
/**
* Latest relay-group rows from the new items, keyed the same way as [relayGroupRowKey]: by group
* id in INLINE mode (one row per group) and by host relay url in GROUPED mode (one row per relay).
* Only actual group content counts — a reaction (kind 7)/deletion/label carries the group's `h`
* tag too, so [Event.isGroupChatContent] gates them out of ever becoming a room's "last message".
*/
private fun filterRelevantRelayGroupMessages(
newItems: Set<Note>,
account: Account,
): MutableMap<String, Note> {
val joined = account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) return mutableMapOf()
return when (account.settings.relayGroupViewMode.value) {
RelayGroupViewMode.INLINE -> {
val joinedGroupIds = joined.mapTo(HashSet()) { it.groupId }
val result = mutableMapOf<String, Note>()
newItems.forEach { newNote ->
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId()
if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) {
val lastNote = result[gid]
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
result[gid] = newNote
}
}
}
result
}
RelayGroupViewMode.GROUPED -> {
val groupToRelay = HashMap<String, NormalizedRelayUrl>()
joined.forEach { tag ->
RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { groupToRelay[tag.groupId] = it }
}
// Newest new message per host relay, collapsed into one per-relay row.
val newestPerRelay = HashMap<NormalizedRelayUrl, Note>()
newItems.forEach { newNote ->
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() ?: return@forEach
val relay = groupToRelay[gid] ?: return@forEach
if (!account.isAcceptable(newNote)) return@forEach
val lastNote = newestPerRelay[relay]
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
newestPerRelay[relay] = newNote
}
}
val result = mutableMapOf<String, Note>()
newestPerRelay.forEach { (relay, note) -> result[relay.url] = RelayGroupServerRoomNote(relay, note) }
result
}
}
}
private fun filterRelevantPrivateMessages(
newItems: Set<Note>,
account: Account,
@@ -0,0 +1,47 @@
/*
* 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
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* A synthetic Messages-list row that collapses ALL of a user's joined NIP-29 groups on one host
* [relay] into a single entry — the "grouped by relay" view mode
* ([com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED]).
*
* It is not a real event: [event] stays null and [createdAt] mirrors [newestMessage] (the newest
* chat across that relay's groups) so the row interleaves with DMs and other chats by recency and
* lands in "the newest message spot". Tapping it opens the relay's group list. Exactly one instance
* exists per relay — never duplicated — keyed by a stable [idHex] so feed diffing and the
* LazyColumn treat it as the same row across refreshes.
*/
class RelayGroupServerRoomNote(
val relay: NormalizedRelayUrl,
val newestMessage: Note?,
) : Note(idFor(relay)) {
override fun createdAt(): Long? = newestMessage?.createdAt()
companion object {
fun idFor(relay: NormalizedRelayUrl): HexKey = "relaygroupserver-${relay.url}"
}
}
@@ -119,12 +119,13 @@ fun MessagesPager(
tabs: List<MessagesTabItem>,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
HorizontalPager(
state = pagerState,
userScrollEnabled = true,
modifier =
Modifier.zonedDrawerSwipe(
modifier.zonedDrawerSwipe(
pagerState = pagerState,
openDrawer = nav::openDrawer,
),
@@ -21,11 +21,13 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
@@ -38,6 +40,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager
@@ -98,11 +102,22 @@ fun MessagesSinglePane(
},
accountViewModel = accountViewModel,
) {
// Keep joined groups' rosters live while the messages list is on top, so
// membership/pending state is accurate inline without opening each chat.
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a cache hit
// when those groups surface in discovery or any gated surface.
WarmJoinedRelayGroupNip11(accountViewModel)
// The inline-vs-grouped NIP-29 display preference lives in Settings › Messages; joined groups
// (or per-relay rows in grouped mode) are woven directly into the feed below.
MessagesPager(
pagerState,
tabs,
accountViewModel,
nav,
modifier = Modifier.fillMaxSize(),
)
}
}
@@ -46,6 +46,8 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.theme.Size20dp
@@ -92,6 +94,14 @@ fun MessagesTwoPane(
TwoPane(
first = {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) {
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a
// cache hit when those groups surface in discovery or any gated surface.
WarmJoinedRelayGroupNip11(accountViewModel)
// The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined
// groups (or per-relay rows in grouped mode) are woven into the list itself.
ChatroomList(
knownFeedContentState,
newFeedContentState,
@@ -43,8 +43,10 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@@ -61,7 +63,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
@@ -85,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -122,6 +127,8 @@ import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
@@ -149,11 +156,14 @@ fun ShortNotePostScreen(
forkId: HexKey? = null,
versionId: HexKey? = null,
draftId: HexKey? = null,
groupThreadId: HexKey? = null,
groupThreadRelayUrl: String? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val postViewModel: ShortNotePostViewModel = viewModel()
postViewModel.init(accountViewModel)
postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl)
val context = LocalContext.current
val activity = context.getActivity()
@@ -347,6 +357,44 @@ private fun NewPostScreenBody(
)
}
if (postViewModel.wantsSubject || postViewModel.groupThreadTarget != null) {
// Styled like the "To"/"Subject" rows in the new-DM composer: an inline label
// with a borderless field and a hairline divider, rather than a boxed input.
Column(Modifier.fillMaxWidth().padding(vertical = Size10dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
Text(
text =
stringRes(
if (postViewModel.groupThreadTarget != null) {
R.string.relay_group_thread_title_label
} else {
R.string.messages_new_message_subject
},
),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
ThinPaddingTextField(
state = postViewModel.subject,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = {
Text(
text = stringRes(R.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
colors =
OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
),
)
}
HorizontalDivider(thickness = DividerThickness)
}
}
// Only show text input if no voice message is being posted
if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) {
Row(
@@ -749,8 +797,9 @@ private fun BottomRowActions(
)
// Polls publish kinds that can't travel inside a private wrap, so the
// two toggles are mutually exclusive.
if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll) {
// two toggles are mutually exclusive. Neither a private wrap nor a poll makes sense for a
// NIP-29 group thread (it publishes plainly to the host relay), so hide both there.
if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll && postViewModel.groupThreadTarget == null) {
AddPrivateNoteButton(
isActive = postViewModel.wantsPrivateNote,
isLocked = postViewModel.privateNoteLocked,
@@ -759,7 +808,10 @@ private fun BottomRowActions(
}
}
if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) && !postViewModel.wantsPrivateNote) {
if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) &&
!postViewModel.wantsPrivateNote &&
postViewModel.groupThreadTarget == null
) {
AddPollButton(postViewModel.wantsPoll || postViewModel.wantsZapPoll) {
val isActive = postViewModel.wantsPoll || postViewModel.wantsZapPoll
if (isActive) {
@@ -781,6 +833,13 @@ private fun BottomRowActions(
}
}
// A group thread's title is required, so the field is always shown for it — no toggle.
if (postViewModel.groupThreadTarget == null) {
AddSubjectButton(postViewModel.wantsSubject) {
postViewModel.toggleSubject()
}
}
MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) {
postViewModel.toggleMarkAsSensitive()
}
@@ -790,8 +849,9 @@ private fun BottomRowActions(
}
// Private wraps are built and sent immediately; scheduling them would
// require wrapping at publish time, so the option is hidden for now.
if (!postViewModel.wantsPrivateNote) {
// require wrapping at publish time, so the option is hidden for now. Scheduling also
// bypasses the host-relay pin, so it's hidden for NIP-29 group threads too.
if (!postViewModel.wantsPrivateNote && postViewModel.groupThreadTarget == null) {
ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
}
@@ -849,6 +909,21 @@ private fun AddPrivateNoteButton(
}
}
@Composable
private fun AddSubjectButton(
isActive: Boolean,
onClick: () -> Unit,
) {
IconButton(onClick = onClick) {
Icon(
symbol = MaterialSymbols.Topic,
contentDescription = stringRes(R.string.messages_new_message_subject),
modifier = Modifier.height(22.dp),
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
)
}
}
@Composable
private fun AddPollButton(
isPollActive: Boolean,
@@ -94,7 +94,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount
import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag
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.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -113,10 +116,12 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip10Notes.tags.markedETags
import com.vitorpamplona.quartz.nip10Notes.tags.notify
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip22Comments.notify
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
@@ -133,6 +138,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
@@ -305,6 +311,11 @@ open class ShortNotePostViewModel :
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
override val forwardZapToEditting = TextFieldState()
// Optional subject line: a NIP-14 `subject` tag on a kind-1 note, or the `title` of a NIP-29
// kind-11 group thread. Toggled from the bottom row; auto-on for group threads.
var wantsSubject by mutableStateOf(false)
val subject = TextFieldState()
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
var contentWarningDescription by mutableStateOf("")
@@ -479,6 +490,30 @@ open class ShortNotePostViewModel :
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
}
/**
* When set, this composer produces a NIP-29 kind-11 group thread instead of a kind-1 note:
* the title is the first line, the rest is the body, and it publishes ONLY to the group's host
* [relays] (relay29 authorizes writes by the `h` tag). Toggles that don't apply to a group thread
* (poll, private note, scheduling) are hidden while this is set.
*/
class GroupThreadTarget(
val groupId: HexKey,
val relays: List<NormalizedRelayUrl>,
)
var groupThreadTarget: GroupThreadTarget? = null
private set
fun setGroupThread(
groupId: HexKey?,
relayUrl: String?,
) {
val relay = relayUrl?.let { RelayUrlNormalizer.normalizeOrNull(it) }
groupThreadTarget = if (groupId != null && relay != null) GroupThreadTarget(groupId, listOf(relay)) else null
// A thread wants a title, so surface the field by default when composing one.
if (groupThreadTarget != null) wantsSubject = true
}
open fun load(
replyingTo: Note?,
quote: Note?,
@@ -920,8 +955,19 @@ open class ShortNotePostViewModel :
val anonymous = wantsAnonymousPost
val scheduledFor = scheduledForSec
val privately = wantsPrivateNote
val threadTarget = groupThreadTarget
cancel()
if (threadTarget != null) {
// NIP-29 group thread: publish only to the group's host relay, never the account's
// outbox — bypass the private/scheduled/anonymous paths entirely.
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { threadTarget.relays }
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
return
}
if (privately && template.kind == TextNoteEvent.KIND) {
// Gift-wrap to the p-tagged users instead of publishing. Private
// wins over the anonymous and scheduled modes: a locked private
@@ -1073,8 +1119,28 @@ open class ShortNotePostViewModel :
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
val subjectValue = subject.text.toString().trim()
return if (wantsPoll) {
val threadTarget = groupThreadTarget
return if (threadTarget != null) {
// NIP-29 kind-11 group thread. The title is the required subject field (no first-line
// fallback); the body is the full message. Scoped to the group by `h`; the host relay
// authorizes the write.
ThreadEvent.build(tagger.message.trim(), subjectValue) {
hTag(threadTarget.groupId)
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
geoHash?.let { geohash(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
} else if (wantsPoll) {
val options = pollOptions.map { it.value }
if (options.isEmpty()) return null
@@ -1200,6 +1266,8 @@ open class ShortNotePostViewModel :
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
if (wantsSubject && subjectValue.isNotBlank()) subject(subjectValue)
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
@@ -1373,6 +1441,8 @@ open class ShortNotePostViewModel :
zapRaiserAmount.value = null
wantsForwardZapTo = false
wantsSubject = false
subject.clearText()
wantsToMarkAsSensitive = false
contentWarningDescription = ""
wantsToAddGeoHash = false
@@ -1502,6 +1572,9 @@ open class ShortNotePostViewModel :
return !isUploadingVoice && !mediaUploadTracker.isUploading && processingPreset == null
}
// A NIP-29 group thread requires a title — it has no first-line fallback.
if (groupThreadTarget != null && subject.text.isBlank()) return false
// Regular text/media posts require text
return message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading &&
@@ -1760,6 +1833,12 @@ open class ShortNotePostViewModel :
draftTag.newVersion()
}
fun toggleSubject() {
wantsSubject = !wantsSubject
if (!wantsSubject) subject.clearText()
draftTag.newVersion()
}
fun toggleExpirationDate() {
wantsExpirationDate = !wantsExpirationDate
if (wantsExpirationDate) {
@@ -0,0 +1,131 @@
/*
* 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.settings
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.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
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.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@Composable
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
fun MessagesSettingsScreenPreview() {
ThemeComparisonRow {
MessagesSettingsScreen(mockAccountViewModel(), EmptyNav())
}
}
/**
* User preferences for the Messages tab. Currently the NIP-29 relay-group display mode: show each
* joined group inline as its own conversation, or collapse each host relay's groups into a single
* row placed at its newest message. Lives here (rather than pinned above the feed) so it doesn't
* crowd the list.
*/
@Composable
fun MessagesSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val mode by accountViewModel.account.settings.relayGroupViewMode
.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(R.string.messages_settings), nav)
},
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringRes(R.string.relay_group_view_mode_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
)
RelayGroupViewModeOption(
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(
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) },
)
}
}
}
@Composable
private fun RelayGroupViewModeOption(
title: String,
description: String,
selected: Boolean,
onSelect: () -> Unit,
) {
androidx.compose.foundation.layout.Row(
modifier =
Modifier
.fillMaxWidth()
.selectable(selected = selected, onClick = onSelect)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onSelect)
Column(Modifier.padding(start = 12.dp)) {
Text(text = title, fontWeight = FontWeight.SemiBold)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -97,6 +97,7 @@ fun buildSettingsCatalog(
symEntry(R.string.notification_settings, MaterialSymbols.Notifications, R.string.notification_settings_search_keywords, Route.NotificationSettings),
symEntry(R.string.home_tabs_settings, MaterialSymbols.Home, R.string.home_tabs_search_keywords, Route.HomeTabsSettings),
symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings),
symEntry(R.string.messages_settings, MaterialSymbols.Forum, R.string.messages_settings_search_keywords, Route.MessagesSettings),
symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings),
symEntry(R.string.profile_ui_settings, MaterialSymbols.AccountCircle, R.string.profile_ui_search_keywords, Route.ProfileUiSettings),
symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings),
+104
View File
@@ -1929,6 +1929,110 @@
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_group">Group</string>
<string name="messages_create_public_private_chat_description">New Public or Private Group</string>
<string name="relay_groups_title">Relay Groups</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">By relay</string>
<string name="relay_group_view_inline_desc">Show each group as its own conversation, mixed in with your chats.</string>
<string name="relay_group_view_grouped_desc">Collapse each relay\'s groups into a single row, placed at its newest message.</string>
<string name="relay_group_view_mode_title">NIP-29 group display</string>
<string name="relay_group_server_label">Relay groups</string>
<string name="messages_settings">Messages</string>
<string name="messages_settings_search_keywords" translatable="false">messages, chats, groups, nip-29, relay, inline, dm</string>
<string name="relay_group_create_title">Create a group</string>
<string name="relay_group_relay_no_nip29">This relay doesn\'t advertise support for NIP-29 relay groups. A group created here won\'t work — its name, members and messages won\'t be managed by the relay. Pick a relay that supports relay-based groups.</string>
<string name="relay_group_create_name">Group name</string>
<string name="relay_group_create_topic">Topic (optional)</string>
<string name="relay_group_create_private">Private (members-only read)</string>
<string name="relay_group_create_invite_only">Invite only</string>
<string name="relay_group_create_confirm">Create</string>
<string name="relay_group_invite_title">Invite people</string>
<string name="relay_group_invite_description">Share this code so people can join this group.</string>
<string name="relay_group_invite_generating">Creating an invite…</string>
<string name="relay_group_invite_copied">Invite code copied</string>
<string name="relay_group_invite_code_label">Invite code (for this invite-only group):</string>
<string name="relay_group_join_title">Join group</string>
<string name="relay_group_join_code_label">Invite code</string>
<string name="relay_group_join_code_hint">This group is invite-only. Enter the code you were given.</string>
<string name="relay_group_join_confirm">Join</string>
<string name="relay_group_pending">Requested</string>
<string name="relay_group_role_admin">Admin</string>
<string name="relay_group_role_moderator">Moderator</string>
<string name="relay_group_role_member">Member</string>
<string name="relay_group_members_title">Members</string>
<string name="relay_group_make_admin">Make admin</string>
<string name="relay_group_make_moderator">Make moderator</string>
<string name="relay_group_demote_member">Remove role</string>
<string name="relay_group_remove_user">Remove from group</string>
<string name="relay_group_remove_user_confirm">Remove %1$s from this group? They will lose access until re-added or re-invited.</string>
<string name="relay_group_edit_title">Edit group</string>
<string name="relay_group_edit_name">Group name</string>
<string name="relay_group_edit_topic">Topic (optional)</string>
<string name="relay_group_edit_private">Private (members-only read)</string>
<string name="relay_group_edit_invite_only">Invite only</string>
<string name="relay_group_edit_confirm">Save</string>
<string name="relay_group_menu_members">Members</string>
<string name="relay_group_menu_edit">Edit group</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_open">Open group</string>
<string name="relay_group_channels_empty">No groups on this relay yet.</string>
<string name="relay_group_invite_preparing">Preparing invite…</string>
<string name="relay_group_badge_private">Private</string>
<string name="relay_group_badge_invite_only">Invite-only</string>
<string name="relay_group_browse_invalid_url">Enter a valid relay URL (wss://…).</string>
<string name="relay_group_field_name">Name</string>
<string name="relay_group_field_about">Description</string>
<string name="relay_group_field_picture">Group picture</string>
<string name="relay_group_add_photo">Add photo</string>
<string name="relay_group_change_photo">Change photo</string>
<string name="relay_group_section_discovery">Discovery</string>
<string name="relay_group_section_discovery_desc">Help people find this group. Topics and a location surface it under the matching discovery filters (if the host relay supports them).</string>
<string name="relay_group_field_topics">Topics</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, art</string>
<string name="relay_group_field_geohash">Location (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_permissions">Permissions</string>
<string name="relay_group_flag_private">Private</string>
<string name="relay_group_flag_private_desc">Only members can read messages.</string>
<string name="relay_group_flag_invite_only">Invite-only</string>
<string name="relay_group_flag_invite_only_desc">People must be invited or approved to join.</string>
<string name="relay_group_flag_restricted">Members-only posting</string>
<string name="relay_group_flag_restricted_desc">Only members can post messages.</string>
<string name="relay_group_flag_hidden">Unlisted</string>
<string name="relay_group_flag_hidden_desc">Hide this group from the relay\'s public directory.</string>
<string name="relay_group_discovery_empty">Looking for groups across your relays…</string>
<string name="relay_group_discovery_empty_filtered">No groups found for this filter yet.</string>
<string name="relay_group_favorite_relay">Favorite this relay</string>
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
<string name="relay_group_thread_new">New thread</string>
<string name="relay_group_thread_untitled">Untitled</string>
<string name="relay_group_thread_title_label">Title</string>
<string name="relay_group_thread_body_label">Write something…</string>
<string name="relay_group_thread_post">Post</string>
<string name="relay_group_browse_title">Find groups</string>
<string name="relay_group_browse_description">Browse the groups hosted on a relay. Paste a relay address, or pick one below.</string>
<string name="relay_group_browse_relay_label">Relay address</string>
<string name="relay_group_browse_go">Browse</string>
<string name="relay_group_browse_your_relays">Relays you\'re on</string>
<string name="relay_group_browse_popular">Popular relays</string>
<string name="relay_group_no_messages_yet">No messages yet</string>
<string name="relay_group_join_to_post">Join this group to send messages.</string>
<string name="relay_group_invite_only_to_post">This group is invite-only — you need an invite to post.</string>
<plurals name="relay_group_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="relay_group_message_count">
<item quantity="one">%1$d message</item>
<item quantity="other">%1$d messages</item>
</plurals>
<plurals name="relay_group_message_count_capped">
<item quantity="one">%1$d+ message</item>
<item quantity="other">%1$d+ messages</item>
</plurals>
<plurals name="relay_group_follows_participating">
<item quantity="one">%1$d person you follow</item>
<item quantity="other">%1$d people you follow</item>
</plurals>
<string name="messages_new_message">Private</string>
<string name="messages_new_message_to">To</string>
<string name="messages_new_message_subject">Subject</string>
@@ -0,0 +1,164 @@
/*
* 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.relayGroup.dal
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The per-type discovery match for relay-signed NIP-29 groups. Verifies the three people
* paths ([relay-key follow / follow-is-admin / follow-is-member]) and the topic/geo tag
* matches, plus that [toGroupConstraints] resolves each top-nav filter to the right shape.
*/
class RelayGroupDiscoveryConstraintTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example/")!!
private val relayKey = "aa".repeat(32)
private val alice = "bb".repeat(32)
private val bob = "cc".repeat(32)
private val carol = "dd".repeat(32)
private val sig = "22".repeat(64)
private fun channel(
metaTags: Array<Array<String>>,
adminPubkeys: List<String> = emptyList(),
memberPubkeys: List<String> = emptyList(),
): RelayGroupChannel {
val gid = "group1"
val ch = RelayGroupChannel(GroupId(gid, relay))
ch.updateGroupInfo(
GroupMetadataEvent("00".repeat(32), relayKey, 1_000L, arrayOf(arrayOf("d", gid), *metaTags), "", sig),
)
if (adminPubkeys.isNotEmpty()) {
ch.updateAdmins(
GroupAdminsEvent(
"11".repeat(32),
relayKey,
1_000L,
arrayOf(arrayOf("d", gid), *adminPubkeys.map { arrayOf("p", it, "admin") }.toTypedArray()),
"",
sig,
),
)
}
if (memberPubkeys.isNotEmpty()) {
ch.updateMembers(
GroupMembersEvent(
"22".repeat(32),
relayKey,
1_000L,
arrayOf(arrayOf("d", gid), *memberPubkeys.map { arrayOf("p", it) }.toTypedArray()),
"",
sig,
),
)
}
return ch
}
@Test
fun allGroupsMatchesAnyLoadedGroup() {
assertTrue(GroupDiscoveryConstraint.AllGroups.matches(channel(arrayOf(arrayOf("name", "Any")))))
}
@Test
fun byPeopleMatchesFollowedRelayKey() {
val c = GroupDiscoveryConstraint.ByPeople(setOf(relayKey))
assertTrue(c.matches(channel(arrayOf(arrayOf("name", "G")))))
}
@Test
fun byPeopleMatchesFollowedAdminAndMember() {
val ch = channel(arrayOf(arrayOf("name", "G")), adminPubkeys = listOf(alice), memberPubkeys = listOf(bob))
assertTrue(GroupDiscoveryConstraint.ByPeople(setOf(alice)).matches(ch))
assertTrue(GroupDiscoveryConstraint.ByPeople(setOf(bob)).matches(ch))
}
@Test
fun byPeopleRejectsStrangers() {
val ch = channel(arrayOf(arrayOf("name", "G")), adminPubkeys = listOf(alice), memberPubkeys = listOf(bob))
assertFalse(GroupDiscoveryConstraint.ByPeople(setOf(carol)).matches(ch))
assertFalse(GroupDiscoveryConstraint.ByPeople(emptySet()).matches(ch))
}
@Test
fun byHashtagsMatchesCaseInsensitively() {
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("t", "Bitcoin")))
assertTrue(GroupDiscoveryConstraint.ByHashtags(setOf("bitcoin")).matches(ch))
assertFalse(GroupDiscoveryConstraint.ByHashtags(setOf("nostr")).matches(ch))
}
@Test
fun byGeohashesMatchesMipMapPrefix() {
// GroupMetadataEvent.build mip-maps geohashes; here we store the full one and a prefix.
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("g", "u0nd"), arrayOf("g", "u0")))
assertTrue(GroupDiscoveryConstraint.ByGeohashes(setOf("u0")).matches(ch))
assertFalse(GroupDiscoveryConstraint.ByGeohashes(setOf("9q")).matches(ch))
}
@Test
fun anyOfMatchesIfEitherLensMatches() {
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("t", "bitcoin")))
val c =
GroupDiscoveryConstraint.AnyOf(
listOf(
GroupDiscoveryConstraint.ByPeople(setOf(carol)),
GroupDiscoveryConstraint.ByHashtags(setOf("bitcoin")),
),
)
assertTrue(c.matches(ch))
}
@Test
fun globalFilterResolvesToAllGroupsPerRelay() {
val set = GlobalTopNavPerRelayFilterSet(mapOf(relay to GlobalTopNavPerRelayFilter))
val constraints = set.toGroupConstraints()
assertEquals(GroupDiscoveryConstraint.AllGroups, constraints[relay])
}
@Test
fun authorsFilterResolvesToByPeople() {
val set = AuthorsTopNavPerRelayFilterSet(mapOf(relay to AuthorsTopNavPerRelayFilter(setOf(alice))))
val constraints = set.toGroupConstraints()
assertEquals(GroupDiscoveryConstraint.ByPeople(setOf(alice)), constraints[relay])
}
@Test
fun hashtagFilterResolvesToByHashtags() {
val set = HashtagTopNavPerRelayFilterSet(mapOf(relay to HashtagTopNavPerRelayFilter(setOf("bitcoin"))))
val constraints = set.toGroupConstraints()
assertTrue(constraints[relay] is GroupDiscoveryConstraint.ByHashtags)
}
}
+21
View File
@@ -449,6 +449,27 @@ the cards. For a third-party observer, `graperank operator providers` prints the
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
### Relay groups (NIP-29)
Relay-based groups (à la Armada / relay29). A group lives on one host relay,
addressed by `(relay, group id)`; every read and write is pinned there. Distinct
from Marmot/MLS above — these are the NIP-29 groups Amethyst's "Relay Groups"
screen speaks.
| Command | What it does |
|---|---|
| `amy relaygroup list` | Your joined groups, from your kind:10009 list (public + private). |
| `amy relaygroup browse RELAY` | Every group a relay hosts (its 39000-39003 directory). |
| `amy relaygroup info RELAY GID` | A group's metadata + admin/member roster. |
| `amy relaygroup create RELAY --name X [--about A] [--private] [--closed]` | Create a group (publishes 9007 + 9002); prints the new `group_id`. |
| `amy relaygroup join RELAY GID [--code CODE]` | Request to join (9021) and add it to your kind:10009 list. |
| `amy relaygroup leave RELAY GID` | Leave (9022) and drop it from your kind:10009 list. |
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads current visibility and changes only the axis you pass, so re-asserting one flag never resets the other. |
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
### CLINK Offers
| Command | What it does |
+4 -4
View File
@@ -123,10 +123,10 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage:
- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`),
`git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs
nak's local hints DB).
- **Missing (7):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
`spell` (MuSig2/FROST), `validate` (event-schema validation), and
`group`/`nip29` (NIP-29 — amy ships MLS/Marmot instead, an intentional
divergence rather than a gap).
- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
`spell` (MuSig2/FROST), and `validate` (event-schema validation).
`relaygroup` (NIP-29) now ships alongside MLS/Marmot — the two group models
are offered side by side rather than one substituting for the other.
**Design differences (not gaps):** amy is a *stateful client* (accounts,
`~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless*
@@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDe
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.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -59,6 +60,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
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.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -75,14 +77,17 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachability
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.utils.SeenIds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/**
@@ -242,6 +247,26 @@ class Context(
.OkHttpNip05Fetcher { _ -> okhttp },
)
/**
* Fetches [relay]'s NIP-11 relay-information document (over the same OkHttp instance), or null if
* it serves none / the request fails. Used to read the relay's `self` pubkey — NIP-29's authority
* for group metadata — so `relaygroup` reads can verify a 39000 was actually signed by the relay.
*/
suspend fun relayInfo(relay: NormalizedRelayUrl): Nip11RelayInformation? =
withContext(Dispatchers.IO) {
runCatching {
val request =
Request
.Builder()
.url(relay.toHttp())
.header("Accept", "application/nostr+json")
.build()
okhttp.newCall(request).execute().use { resp ->
resp.body?.string()?.let { Nip11RelayInformation.fromJson(it) }
}
}.getOrNull()
}
// Lazy so an anonymous read (no account dir) never materialises the
// per-account marmot stores — constructing them would `mkdir` group dirs
// under the shared root. Real accounts build them on first marmot use.
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
@@ -257,6 +258,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"whoami" -> InitCommands.whoami(dataDir)
"relay" -> RelayCommands.dispatch(dataDir, tail)
"marmot" -> marmotDispatch(dataDir, tail)
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
"dm" -> DmCommands.dispatch(dataDir, tail)
"profile" -> ProfileCommands.dispatch(dataDir, tail)
"notes" -> NotesCommands.dispatch(dataDir, tail)
@@ -671,6 +673,23 @@ private fun printUsage() {
| dm await --peer NPUB --match TEXT wait for a matching DM
| [--timeout SECS] (default 30s, exit 124 on timeout)
|
|Relay groups (NIP-29):
| relaygroup list joined groups (from kind:10009)
| relaygroup browse RELAY every group a relay hosts
| relaygroup info RELAY GID a group's metadata + roster
| relaygroup create RELAY --name NAME create a group (publishes 9007+9002)
| [--about A] [--private] [--closed]
| relaygroup join RELAY GID [--code CODE] request to join (kind 9021)
| relaygroup leave RELAY GID leave (kind 9022)
| relaygroup message RELAY GID TEXT post a kind-9 chat to the group
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin);
| [--about A] [--private|--public] reads current visibility and only
| [--closed|--open] changes the axis you specify
| relaygroup invite RELAY GID --code CODE mint an invite code (kind 9009)
| relaygroup put-user RELAY GID PUBKEY add/promote a user (kind 9000)
| [--role admin|moderator]
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
|Marmot (MLS group messaging):
| marmot key-package publish publish a fresh KeyPackage
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
@@ -0,0 +1,275 @@
/*
* 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.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
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.signers.EventTemplate
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* `amy relaygroup …` — NIP-29 relay-based groups. Every group lives on exactly
* one host relay and is addressed by (relay, group id); all reads and writes are
* pinned to that relay. This object owns the lifecycle verbs (create/join/leave/
* message); reads live in [RelayGroupReadCommands], moderation in
* [RelayGroupModerationCommands].
*/
object RelayGroupCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"relaygroup",
tail,
"relaygroup <list|browse|info|create|join|leave|message|edit|invite|put-user|remove-user> …",
mapOf(
"list" to { rest -> RelayGroupReadCommands.list(dataDir, rest) },
"browse" to { rest -> RelayGroupReadCommands.browse(dataDir, rest) },
"info" to { rest -> RelayGroupReadCommands.info(dataDir, rest) },
"create" to { rest -> create(dataDir, rest) },
"join" to { rest -> join(dataDir, rest) },
"leave" to { rest -> leave(dataDir, rest) },
"message" to { rest -> message(dataDir, rest) },
"edit" to { rest -> RelayGroupModerationCommands.edit(dataDir, rest) },
"invite" to { rest -> RelayGroupModerationCommands.invite(dataDir, rest) },
"put-user" to { rest -> RelayGroupModerationCommands.putUser(dataDir, rest) },
"remove-user" to { rest -> RelayGroupModerationCommands.removeUser(dataDir, rest) },
),
)
/** `relaygroup create RELAY --name NAME [--about A] [--private] [--closed]` → publishes 9007 + 9002. */
private suspend fun create(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup create RELAY --name NAME [--about A] [--private] [--closed]")
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val name = args.flag("name") ?: return Output.error("bad_args", "relaygroup create requires --name")
val about = args.flag("about")
val isPrivate = args.bool("private")
val isClosed = args.bool("closed")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val groupId = RandomInstance.bytes(8).toHexKey()
val target = setOf(relay)
val createAck = ctx.publish(ctx.signer.sign(CreateGroupEvent.build(groupId)), target)
val status = groupStatus(isPrivate, isClosed)
val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status)
val editAck = ctx.publish(ctx.signer.sign(edit), target)
// Track it in our own kind:10009 so `relaygroup list` shows it, matching
// the Android create flow (Account.createRelayGroup → follow).
val listed = updateGroupList(ctx, relay, groupId, add = true)
Output.emit(
mapOf(
"group_id" to groupId,
"relay" to relay.url,
"name" to name,
"private" to isPrivate,
"closed" to isClosed,
"published" to (createAck.values.any { it } && editAck.values.any { it }),
"listed" to listed,
),
)
return 0
}
}
/**
* `relaygroup join RELAY GROUP_ID [--code CODE] [--reason R]` — publishes the
* 9021 join request to the host relay AND adds the group to the caller's
* kind-10009 list (private item), so `relaygroup list` reflects it.
*/
private suspend fun join(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "relaygroup join RELAY GROUP_ID [--code CODE]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val join = JoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = args.flag("code"))
val ack = ctx.publish(ctx.signer.sign(join), setOf(relay))
val listed = updateGroupList(ctx, relay, groupId, add = true)
Output.emit(
mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it }, "listed" to listed),
)
return 0
}
}
/**
* `relaygroup leave RELAY GROUP_ID` — publishes the 9022 leave request to the
* host relay AND removes the group from the caller's kind-10009 list.
*/
private suspend fun leave(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "relaygroup leave RELAY GROUP_ID"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val ack = ctx.publish(ctx.signer.sign(LeaveRequestEvent.build(groupId)), setOf(relay))
val listed = updateGroupList(ctx, relay, groupId, add = false)
Output.emit(
mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it }, "listed" to listed),
)
return 0
}
}
/** `relaygroup message RELAY GROUP_ID <text>` → publishes a kind-9 chat with an `h` tag. */
private suspend fun message(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val text = args.positionalOrNull(2) ?: return Output.error("bad_args", "relaygroup message RELAY GROUP_ID <text>")
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
return publishScoped(dataDir, rest, "relaygroup message RELAY GROUP_ID <text>") { _, groupId, _ ->
ChatEvent.build(text) { hTag(groupId) }
}
}
}
/** Normalize a user-supplied relay URL for a group, or null if unparseable. */
internal fun normalizeGroupRelay(url: String): NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(url.trim())
/**
* Add or remove a group from the caller's kind-10009 "simple groups" list (private
* item) and publish the new version to their outbox relays. Mirrors the Android
* follow/unfollow. Returns true when the updated list was published to ≥1 relay.
*/
private suspend fun updateGroupList(
ctx: Context,
relay: NormalizedRelayUrl,
groupId: String,
add: Boolean,
): Boolean {
val outbox = ctx.outboxRelays()
if (outbox.isEmpty()) return false
// Load the current list from BOTH the local store (amy's source of truth —
// every list we've published/synced is here) and a fresh relay drain, then
// take the newest. Relying on the drain alone is unsafe: a slow or empty
// fetch would look like "no list", and the `create` branch below would then
// replace the user's entire kind:10009 with just this one group.
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
val stored = ctx.latestReplaceable(ctx.identity.pubKeyHex, SimpleGroupListEvent.KIND) as? SimpleGroupListEvent
val drained =
ctx
.drain(outbox.associateWith { listOf(filter) }, 5_000)
.map { it.second }
.filterIsInstance<SimpleGroupListEvent>()
.maxByOrNull { it.createdAt }
val current = listOfNotNull(stored, drained).maxByOrNull { it.createdAt }
val tag = GroupTag(groupId, relay.url, null)
// Public tags, matching the app and the reference NIP-29 clients (membership is
// already public via the relay's kind-39002 list); reads still merge legacy
// private items.
val updated =
when {
add && current == null -> SimpleGroupListEvent.create(publicGroups = listOf(tag), signer = ctx.signer)
add -> SimpleGroupListEvent.add(current!!, tag, isPrivate = false, signer = ctx.signer)
current == null -> return false // nothing to remove from
else -> SimpleGroupListEvent.remove(current, tag, signer = ctx.signer)
}
return ctx.publish(updated, outbox).values.any { it }
}
/**
* The NIP-29 status flag set for the given visibility. NIP-29 flags are
* presence-only: a group is public/open by the ABSENCE of the private/closed
* tags, so we emit only the restrictive flags that are actually on — never a
* `["public"]`/`["open"]` tag (which are non-canonical and can confuse relays).
*/
internal fun groupStatus(
isPrivate: Boolean,
isClosed: Boolean,
): Set<GroupMetadataEvent.GroupStatus> =
buildSet {
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
}
/**
* Shared skeleton for the group-scoped write verbs: parse RELAY + GROUP_ID from
* positionals 0/1, build a template pinned to that group, sign, publish to the
* host relay, and emit the ack. The [build] lambda returns the event template.
*/
internal suspend fun publishScoped(
dataDir: DataDir,
rest: Array<String>,
usage: String,
build: (relay: NormalizedRelayUrl, groupId: String, args: Args) -> EventTemplate<out Event>,
): Int {
val args = Args(rest)
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val signed = ctx.signer.sign(build(relay, groupId, args))
val ack = ctx.publish(signed, setOf(relay))
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"group_id" to groupId,
"relay" to relay.url,
"published" to ack.values.any { it },
),
)
return 0
}
}
@@ -0,0 +1,199 @@
/*
* 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.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
/**
* Moderator/admin write verbs for a relay group (the relay is the final authority
* and rejects a request from someone lacking the role). All pinned to the group's
* host relay via [publishScoped] or a direct publish.
*/
object RelayGroupModerationCommands {
/**
* `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]` → 9002.
*
* A kind-9002 edit re-asserts the group's status flags, so sending only one
* axis would silently reset the other (e.g. `--closed` on a private group
* would drop `private` and leak it public). To avoid that we read the group's
* current 39000 metadata and merge: each axis keeps its current value unless
* the caller explicitly changes it with the flag or its counter-flag.
*/
suspend fun edit(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val filter =
Filter(kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1)
val meta =
ctx
.drain(mapOf(relay to listOf(filter)), 6_000)
.map { it.second }
.filterIsInstance<GroupMetadataEvent>()
.maxByOrNull { it.createdAt }
if (meta == null) {
System.err.println(
"warning: could not read current metadata for $groupId on ${relay.url}; " +
"visibility will be set from the flags given only",
)
}
val isPrivate =
if (args.bool("private")) {
true
} else if (args.bool("public")) {
false
} else {
(meta?.isPrivate() ?: false)
}
val isClosed =
if (args.bool("closed")) {
true
} else if (args.bool("open")) {
false
} else {
(meta?.isClosed() ?: false)
}
// A kind-9002 re-asserts the whole metadata, so omitted fields must be carried over
// from the current 39000 — otherwise editing only a flag would wipe name/about/tags.
val signed =
ctx.signer.sign(
EditMetadataEvent.build(
groupId,
name = args.flag("name") ?: meta?.name(),
about = args.flag("about") ?: meta?.about(),
status = groupStatus(isPrivate, isClosed),
hashtags = meta?.hashtags() ?: emptyList(),
geohashes = meta?.geohashes()?.maxByOrNull { it.length }?.let { listOf(it) } ?: emptyList(),
),
)
val ack = ctx.publish(signed, setOf(relay))
Output.emit(
mapOf(
"event_id" to signed.id,
"group_id" to groupId,
"relay" to relay.url,
"private" to isPrivate,
"closed" to isClosed,
"published" to ack.values.any { it },
),
)
return 0
}
}
/** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */
suspend fun invite(
dataDir: DataDir,
rest: Array<String>,
): Int {
val code = Args(rest).flag("code") ?: return Output.error("bad_args", "relaygroup invite RELAY GROUP_ID --code CODE")
return publishScoped(dataDir, rest, "relaygroup invite RELAY GROUP_ID --code CODE") { _, groupId, _ ->
CreateInviteEvent.build(groupId, code)
}
}
/** `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]` → 9000. */
suspend fun putUser(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val roles =
args
.flag("role")
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() } ?: emptyList()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val pubkey = ctx.requireUserHex(user)
val signed = ctx.signer.sign(PutUserEvent.build(groupId, listOf(pubkey to roles)))
val ack = ctx.publish(signed, setOf(relay))
Output.emit(
mapOf(
"event_id" to signed.id,
"group_id" to groupId,
"relay" to relay.url,
"pubkey" to pubkey,
"roles" to roles,
"published" to ack.values.any { it },
),
)
return 0
}
}
/** `relaygroup remove-user RELAY GROUP_ID PUBKEY` → 9001. */
suspend fun removeUser(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "relaygroup remove-user RELAY GROUP_ID PUBKEY"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val pubkey = ctx.requireUserHex(user)
val signed = ctx.signer.sign(RemoveUserEvent.build(groupId, listOf(pubkey)))
val ack = ctx.publish(signed, setOf(relay))
Output.emit(
mapOf(
"event_id" to signed.id,
"group_id" to groupId,
"relay" to relay.url,
"pubkey" to pubkey,
"published" to ack.values.any { it },
),
)
return 0
}
}
}
@@ -0,0 +1,197 @@
/*
* 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.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
/**
* Read verbs for relay groups: the user's joined list (kind 10009), a relay's
* whole hosted directory, and a single group's metadata + roster. All reads drain
* a one-shot subscription and parse with Quartz's NIP-29 events.
*/
object RelayGroupReadCommands {
/** `relaygroup list` — the caller's joined groups from their kind-10009 list (public + private). */
suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays = ctx.outboxRelays()
if (relays.isEmpty()) return Output.error("no_relays", "no relays configured; run `amy relay add`")
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
val latest =
ctx
.drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
.map { it.second }
.filterIsInstance<SimpleGroupListEvent>()
.maxByOrNull { it.createdAt }
val groups =
latest?.let { event ->
val pub = event.publicGroups()
val priv = event.privateGroups(ctx.signer) ?: emptyList()
(pub + priv).distinctBy { it.groupId to it.relayUrl }
} ?: emptyList()
Output.emit(
mapOf(
"count" to groups.size,
"groups" to
groups.map {
mapOf("group_id" to it.groupId, "relay" to it.relayUrl, "name" to it.name)
},
),
)
return 0
}
}
/** `relaygroup browse RELAY [--timeout S]` — every group the relay hosts (its 39000 directory). */
suspend fun browse(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup browse RELAY")
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
ctx.prepare()
// NIP-29 requires the 39000 to be signed by the relay's own key; verify against the
// relay's NIP-11 `self` so stray user-published 39000s on a general relay are dropped.
val relaySigned = relaySignedFilter(ctx.relayInfo(relay))
val filter = Filter(kinds = listOf(GroupMetadataEvent.KIND), limit = 500)
val allMetas =
ctx
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
.map { it.second }
.filterIsInstance<GroupMetadataEvent>()
.distinctBy { it.groupId() }
val metas =
allMetas
.filter { relaySigned(it.pubKey) }
.sortedBy { it.name()?.lowercase() ?: it.groupId() }
Output.emit(
mapOf(
"relay" to relay.url,
"count" to metas.size,
"unverified_dropped" to (allMetas.size - metas.size),
"groups" to metas.map(::metaSummary),
),
)
return 0
}
}
/** `relaygroup info RELAY GROUP_ID [--timeout S]` — one group's metadata + admin/member roster. */
suspend fun info(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup info RELAY GROUP_ID")
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", "relaygroup info RELAY GROUP_ID")
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val filter =
Filter(
kinds = listOf(GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND),
tags = mapOf("d" to listOf(groupId)),
limit = 10,
)
val relaySigned = relaySignedFilter(ctx.relayInfo(relay))
val events =
ctx
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
.map { it.second }
// Only trust relay-signed metadata/roster (39xxx author == the relay's `self`).
.filter { relaySigned(it.pubKey) }
val meta = events.filterIsInstance<GroupMetadataEvent>().maxByOrNull { it.createdAt }
val admins = events.filterIsInstance<GroupAdminsEvent>().maxByOrNull { it.createdAt }?.admins() ?: emptyList()
val members = events.filterIsInstance<GroupMembersEvent>().maxByOrNull { it.createdAt }?.members() ?: emptyList()
val memberCount = (members + admins.map { it.pubKey }).distinct().size
if (meta == null && admins.isEmpty() && members.isEmpty()) {
return Output.error("not_found", "no group $groupId found on ${relay.url}")
}
Output.emit(
mapOf(
"group_id" to groupId,
"relay" to relay.url,
"name" to meta?.name(),
"about" to meta?.about(),
"picture" to meta?.picture(),
"private" to (meta?.isPrivate() ?: false),
"closed" to (meta?.isClosed() ?: false),
"member_count" to memberCount,
"admins" to admins.map { mapOf("pubkey" to it.pubKey, "roles" to it.roles) },
"members" to members,
),
)
return 0
}
}
/**
* A predicate for whether a 39xxx author is the host relay's approved key. NIP-29: metadata is
* "signed by the relay keypair directly … as stated by the NIP-11 `self` pubkey". When the relay
* publishes `self` we enforce it strictly; when it omits `self` we fall back to the weaker
* "advertises NIP-29" signal; a relay with neither yields no genuine groups.
*/
private fun relaySignedFilter(info: Nip11RelayInformation?): (HexKey) -> Boolean {
val self = info?.self
return when {
self != null -> { author -> author == self }
info?.supported_nips?.any { it == "29" } == true -> { _ -> true }
else -> { _ -> false }
}
}
private fun metaSummary(meta: GroupMetadataEvent) =
mapOf(
"group_id" to meta.groupId(),
"name" to meta.name(),
"about" to meta.about(),
"private" to meta.isPrivate(),
"closed" to meta.isClosed(),
)
}
+1
View File
@@ -3,4 +3,5 @@ marmot/state-headless/
dm/state-dm-headless/
nests/state/
clink/state-clink-headless/
relaygroup/state-relaygroup-headless/
sync/state-sync-deletions/
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
#
# relaygroup-headless.sh — end-to-end check of `amy relaygroup` (NIP-29) against
# an embedded relay (`amy serve`, which boots :geode). No external relay binary.
#
# What it proves:
# 1. relaygroup create publishes 9007 + 9002 and returns a group_id.
# 2. relaygroup message publishes a kind-9 chat scoped to the group.
# 3. relaygroup join publishes 9021 AND adds the group to the kind:10009 list.
# 4. relaygroup list reads that list back (private NIP-44 item decrypts).
# 5. relaygroup browse emits well-formed JSON.
#
# geode is not a NIP-29 relay (it does not sign 39000-39003), so browse/info
# return empty — that's a relay capability, not a client bug, and is out of
# scope here. We assert the client's publish + list-maintenance round-trip.
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-relaygroup-headless"
LOG_DIR="$STATE_DIR/logs"
LOG_FILE="$LOG_DIR/run.log"
RESULTS_FILE="$STATE_DIR/results"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
PORT="${PORT:-7466}"
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift ;;
--no-build) NO_BUILD=1 ;;
-h|--help) sed -n '3,17p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --json "$@"; }
SERVE_PID=""
cleanup() {
[[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null
wait "$SERVE_PID" 2>/dev/null
}
trap cleanup EXIT
if [[ "$NO_BUILD" -eq 0 ]]; then
step "building :cli:installDist"
( cd "$REPO_ROOT" && ./gradlew -q :cli:installDist ) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "installDist failed (see $LOG_FILE)"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing at $AMY_BIN"; exit 1; }
banner "relaygroup headless (embedded relay on port $PORT)"
step "init account A"
amy_a init >>"$LOG_FILE" 2>&1 || { fail_msg "init failed"; exit 1; }
step "boot embedded relay (amy serve)"
HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext serve --port "$PORT" >"$LOG_DIR/serve.log" 2>&1 &
SERVE_PID=$!
for _ in $(seq 1 30); do grep -q "listening" "$LOG_DIR/serve.log" 2>/dev/null && break; sleep 0.5; done
grep -q "listening" "$LOG_DIR/serve.log" || { fail_msg "relay did not start"; exit 1; }
RELAY="ws://127.0.0.1:$PORT"
amy_a relay add "$RELAY" >>"$LOG_FILE" 2>&1 || { fail_msg "relay add failed"; exit 1; }
# 1. create
step "relaygroup create"
CREATE=$(amy_a relaygroup create "$RELAY" --name "Headless Group" --about "hi" --closed 2>>"$LOG_FILE")
printf '%s\n' "$CREATE" >>"$LOG_FILE"
GID=$(printf '%s' "$CREATE" | jq -r '.group_id // empty')
if [[ -n "$GID" && "$(printf '%s' "$CREATE" | jq -r '.published')" == "true" ]]; then
pass_msg "create → $GID"; record_result create pass
else
fail_msg "create did not publish"; record_result create fail
fi
# 2. message
step "relaygroup message"
MSG=$(amy_a relaygroup message "$RELAY" "$GID" "gm from the harness" 2>>"$LOG_FILE")
if [[ "$(printf '%s' "$MSG" | jq -r '.published')" == "true" && "$(printf '%s' "$MSG" | jq -r '.kind')" == "9" ]]; then
pass_msg "message → kind 9 published"; record_result message pass
else
fail_msg "message did not publish"; record_result message fail
fi
# 3. join (updates kind:10009)
step "relaygroup join"
JOIN=$(amy_a relaygroup join "$RELAY" "$GID" 2>>"$LOG_FILE")
if [[ "$(printf '%s' "$JOIN" | jq -r '.published')" == "true" && "$(printf '%s' "$JOIN" | jq -r '.listed')" == "true" ]]; then
pass_msg "join → published + listed"; record_result join pass
else
fail_msg "join did not publish/list"; record_result join fail
fi
# 4. list (reads kind:10009 back, decrypting the private item)
step "relaygroup list"
LIST=$(amy_a relaygroup list 2>>"$LOG_FILE")
if printf '%s' "$LIST" | jq -e --arg g "$GID" '.groups[] | select(.group_id == $g)' >/dev/null 2>&1; then
pass_msg "list contains the joined group"; record_result list pass
else
fail_msg "list missing the joined group: $LIST"; record_result list fail
fi
# 5. browse emits well-formed JSON (empty against geode)
step "relaygroup browse"
BROWSE=$(amy_a relaygroup browse "$RELAY" --timeout 3 2>>"$LOG_FILE")
if printf '%s' "$BROWSE" | jq -e '.count != null' >/dev/null 2>&1; then
pass_msg "browse emitted well-formed JSON"; record_result browse pass
else
fail_msg "browse JSON malformed: $BROWSE"; record_result browse fail
fi
print_summary
# results are "<id>\t<pass|fail|skip>"; any fail row → nonzero exit.
awk -F'\t' '$2=="fail"{f=1} END{exit f}' "$RESULTS_FILE" && exit 0 || exit 1
@@ -0,0 +1,91 @@
/*
* 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.nip29RelayGroups
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* How a NIP-29 group qualifies for a discovery view, evaluated against a [RelayGroupChannel].
*
* A group's kind-39000 is relay-signed, so the naive "author = a follow" match the note feeds use
* never fires (the author is the relay). But the *people* dimension is still meaningful — it lives
* in the roster events and the relay's own key:
* - the relay signing the 39000 may be a followed key ([ByPeople] relay-key path);
* - a follow may be a group **admin** (kind 39001) or **member** (kind 39002).
*
* Topics/geo aren't defined by NIP-29, but a cooperating relay can copy requested `t`/`g` tags onto
* the 39000 ([ByHashtags]/[ByGeohashes]).
*
* This is the platform-agnostic match; front ends map their own top-nav filter onto these (see the
* Android `toGroupConstraints` mapping) and Desktop/CLI can reuse the same matcher.
*/
sealed interface GroupDiscoveryConstraint {
fun matches(channel: RelayGroupChannel): Boolean
/** Every group the relay hosts (Global, or a specific relay chip). */
data object AllGroups : GroupDiscoveryConstraint {
override fun matches(channel: RelayGroupChannel) = channel.event != null
}
/** I follow the relay key, or a follow is an admin/member of the group. */
data class ByPeople(
val pubkeys: Set<HexKey>,
) : GroupDiscoveryConstraint {
override fun matches(channel: RelayGroupChannel): Boolean {
if (pubkeys.isEmpty()) return false
val relayKey = channel.event?.pubKey
return (relayKey != null && relayKey in pubkeys) ||
channel.admins.any { it.pubKey in pubkeys } ||
channel.members.any { it in pubkeys }
}
}
/** The 39000 carries a `t` tag matching one of these topics (compared lowercase). */
data class ByHashtags(
val hashtags: Set<String>,
) : GroupDiscoveryConstraint {
private val lower = hashtags.mapTo(mutableSetOf()) { it.lowercase() }
override fun matches(channel: RelayGroupChannel): Boolean {
if (lower.isEmpty()) return false
return channel.event?.hashtags()?.any { it.lowercase() in lower } == true
}
}
/** The 39000 carries a `g` tag matching one of these geohashes (mip-map prefixes intersect). */
data class ByGeohashes(
val geohashes: Set<String>,
) : GroupDiscoveryConstraint {
private val lower = geohashes.mapTo(mutableSetOf()) { it.lowercase() }
override fun matches(channel: RelayGroupChannel): Boolean {
if (lower.isEmpty()) return false
return channel.event?.geohashes()?.any { it.lowercase() in lower } == true
}
}
/** All-follows big-OR: a group matches if ANY of its people/topic/geo lenses match. */
data class AnyOf(
val constraints: List<GroupDiscoveryConstraint>,
) : GroupDiscoveryConstraint {
override fun matches(channel: RelayGroupChannel) = constraints.any { it.matches(channel) }
}
}
@@ -0,0 +1,228 @@
/*
* 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.nip29RelayGroups
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.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* A NIP-29 relay-based group ("channel" in Discord terms). Unlike a NIP-C7
* [com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel] — a
* bare relay+room with no server-owned metadata — a relay group has a
* relay-signed metadata event (kind 39000) carrying its name, picture, about and
* status flags, so this channel is metadata-backed like a NIP-28
* [com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel].
*
* It is keyed by [GroupId] (host relay + group id): the group only exists on its
* host relay, so every read and write is pinned there via [relays].
*/
@Stable
class RelayGroupChannel(
val groupId: GroupId,
) : Channel() {
/** The latest relay-signed kind 39000 metadata event, when known. */
var event: GroupMetadataEvent? = null
/** Long-term reference to the metadata note (LocalCache holds weak references). */
var metadataNote: Note? = null
var updatedMetadataAt: Long = 0
private set
/** Relay-signed member pubkeys (kind 39002). */
var members: Set<HexKey> = emptySet()
private set
private var membersUpdatedAt: Long = 0
/** Relay-signed admins with their roles (kind 39001). */
var admins: List<GroupAdminTag> = emptyList()
private set
private var adminsUpdatedAt: Long = 0
/**
* Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery
* feed read this per note / per recomposition, so caching it avoids rebuilding the set each read.
*/
private var allMembers: Set<HexKey> = emptySet()
private fun recomputeAllMembers() {
allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey }
}
/**
* Kind-11 threads (forum-style posts) scoped to this group, kept apart from the
* kind-9 chat [Channel.notes] so the chat feed stays clean. Surfaced through the
* group's "Threads" view; each thread's own comment tree (kind 1111) is loaded
* on demand by the thread-detail screen.
*/
private val threadNotes = LargeCache<HexKey, Note>()
private val _threads = MutableStateFlow<List<Note>>(emptyList())
val threads: StateFlow<List<Note>> = _threads
private fun republishThreads() {
_threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L }
}
/** Add a kind-11 thread note; newest-first snapshot is re-published to [threads]. */
fun addThread(note: Note) {
if (threadNotes.containsKey(note.idHex)) return
threadNotes.put(note.idHex, note)
republishThreads()
}
fun removeThread(note: Note) {
if (!threadNotes.containsKey(note.idHex)) return
threadNotes.remove(note.idHex)
republishThreads()
}
fun threadCount(): Int = threadNotes.size()
/** A relay group lives on exactly one relay: its host. */
override fun relays() = setOf(groupId.relayUrl)
override fun toBestDisplayName(): String = event?.name() ?: groupId.id
fun summary(): String? = event?.about()
fun profilePicture(): String? = event?.picture()
fun isPrivate(): Boolean = event?.isPrivate() ?: false
fun isRestricted(): Boolean = event?.isRestricted() ?: false
fun isClosed(): Boolean = event?.isClosed() ?: false
fun isHidden(): Boolean = event?.isHidden() ?: false
fun hasLivekit(): Boolean = event?.hasLivekit() ?: false
fun updateGroupInfo(
event: GroupMetadataEvent,
eventNote: Note? = null,
) {
// Only newer metadata supersedes; equal-or-older is dropped, so a duplicate
// arrival isn't reprocessed (no redundant emit) and first-arrival wins on a
// createdAt tie. First load passes since real events have createdAt > 0.
if (event.createdAt <= updatedMetadataAt) return
this.event = event
this.metadataNote = eventNote
this.updatedMetadataAt = event.createdAt
updateChannelInfo()
}
fun updateMembers(event: GroupMembersEvent) {
if (event.createdAt <= membersUpdatedAt) return
members = event.members().toSet()
membersUpdatedAt = event.createdAt
recomputeAllMembers()
updateChannelInfo()
}
fun updateAdmins(event: GroupAdminsEvent) {
if (event.createdAt <= adminsUpdatedAt) return
admins = event.admins()
adminsUpdatedAt = event.createdAt
recomputeAllMembers()
updateChannelInfo()
}
/**
* The shareable NIP-19 `naddr` coordinate for this group's metadata (kind
* 39000, authored by the relay's own key, with the host relay as a hint), or
* null until the relay-signed metadata has loaded (we need its author key).
*/
fun toNAddr(): String? {
val relaySelf = event?.pubKey ?: return null
return NAddress.create(GroupMetadataEvent.KIND, relaySelf, groupId.id, groupId.relayUrl)
}
/** Number of known members (admins are members too). */
fun memberCount(): Int = allMembers.size
/**
* The subset of this group's members/admins that [follows] contains — "people you follow who
* are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition.
*/
fun participatingFollows(follows: Set<HexKey>): List<HexKey> {
if (follows.isEmpty() || allMembers.isEmpty()) return emptyList()
return allMembers.filter { it in follows }
}
/**
* The relay's view of [pubkey]'s membership, from the signed admin/member
* lists. Returns [RelayGroupMembership.NONE] when not in either list (or when
* the lists haven't loaded yet).
*/
fun membershipOf(pubkey: HexKey): RelayGroupMembership {
val admin = admins.firstOrNull { it.pubKey == pubkey }
if (admin != null) {
return when {
admin.roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> RelayGroupMembership.ADMIN
// Presence in the kind-39001 admins list IS the moderation signal;
// the role labels (moderator, ceo, owner, …) are relay-defined. So
// anyone in that list who isn't the top-level admin is at least a
// moderator — never demote them to a plain member just because the
// role string is unrecognized or absent.
else -> RelayGroupMembership.MODERATOR
}
}
return if (pubkey in members) RelayGroupMembership.MEMBER else RelayGroupMembership.NONE
}
fun anyNameStartsWith(prefix: String): Boolean =
groupId.id.contains(prefix, true) ||
event?.name()?.contains(prefix, true) == true ||
event?.about()?.contains(prefix, true) == true
// Synthetic note representing this group in list views (the Messages tab) before any message
// has loaded — so a group the user just joined shows up immediately instead of waiting for its
// first cached kind-9. Mirrors MarmotGroupChatroom.placeholderNote(): adds this channel as a
// gatherer so the list row resolves back to it, and is cached with a stable id so equality-based
// feed diffing treats it as the same row across refreshes.
private val placeholderLock = KmpLock()
private var cachedPlaceholder: Note? = null
fun placeholderNote(): Note =
placeholderLock.withLock {
cachedPlaceholder ?: Note(placeholderIdHex(groupId)).apply {
addGatherer(this@RelayGroupChannel)
cachedPlaceholder = this
}
}
companion object {
fun placeholderIdHex(groupId: GroupId): HexKey = "relaygroup-empty-${groupId.toKey()}"
}
}
@@ -0,0 +1,47 @@
/*
* 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.nip29RelayGroups
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.groupSet
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.groups
/**
* Decrypt-once cache for the NIP-51 simple-groups list (kind 10009). The list's
* joined groups can be public tags or NIP-44 private items (encrypted to self);
* this merges both and parses the `group` items, memoizing the decrypt so the
* always-on observers don't each pay a signer round-trip.
*/
class RelayGroupListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<SimpleGroupListEvent>(signer)
fun cachedGroupSet(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagListPrecached(event).groupSet()
fun cachedGroups(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagListPrecached(event).groups()
suspend fun groupSet(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagList(event).groupSet()
suspend fun groups(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagList(event).groups()
}
@@ -0,0 +1,155 @@
/*
* 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.nip29RelayGroups
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.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
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 10009 event (offline backup). */
interface RelayGroupRepository {
fun relayGroupList(): SimpleGroupListEvent?
fun updateRelayGroupListTo(newRelayGroupList: SimpleGroupListEvent?)
}
/**
* The user's NIP-51 "simple groups" list (kind 10009): the cross-device source
* of truth for which NIP-29 groups they've joined. Joined groups are written as
* PUBLIC `["group", id, relay, name?]` tags (see [follow] for why); reads still
* merge in any NIP-44-encrypted private items so older lists keep working. Mirrors
* [com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState].
*/
class RelayGroupListState(
val signer: NostrSigner,
val cache: ICacheProvider,
val decryptionCache: RelayGroupListDecryptionCache,
val scope: CoroutineScope,
val settings: RelayGroupRepository,
) {
// Long-term reference so the GC doesn't collect the note itself.
val relayGroupListNote = cache.getOrCreateAddressableNote(getRelayGroupListAddress())
fun getRelayGroupListAddress() = SimpleGroupListEvent.createAddress(signer.pubKey)
fun getRelayGroupListFlow(): StateFlow<NoteState> = relayGroupListNote.flow().metadata.stateFlow
fun getRelayGroupList(): SimpleGroupListEvent? = relayGroupListNote.event as? SimpleGroupListEvent
suspend fun relayGroupListWithBackup(note: Note): Set<GroupTag> {
val event = note.event as? SimpleGroupListEvent ?: settings.relayGroupList()
return event?.let { decryptionCache.groupSet(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveRelayGroupList: StateFlow<Set<GroupTag>> =
getRelayGroupListFlow()
.transformLatest { noteState ->
emit(relayGroupListWithBackup(noteState.note))
}.onStart {
emit(relayGroupListWithBackup(relayGroupListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
/** The distinct host relays across all joined groups — the "servers" rail. */
@OptIn(ExperimentalCoroutinesApi::class)
val liveRelayGroupServers: StateFlow<Set<String>> =
liveRelayGroupList
.transformLatest { groups -> emit(groups.mapTo(mutableSetOf()) { it.relayUrl }) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
private fun RelayGroupChannel.toGroupTag() = GroupTag(groupId.id, groupId.relayUrl.url, event?.name())
suspend fun follow(channel: RelayGroupChannel): SimpleGroupListEvent {
val relayGroupList = getRelayGroupList()
val group = channel.toGroupTag()
// Store joined groups as PUBLIC `["group", id, relay]` tags. NIP-29 group
// membership is already public on the relay (the kind-39002 members list),
// so private storage buys almost no privacy — and the reference NIP-29
// clients (Flotilla, nostrord) read only the public tags, so a private list
// makes our joined groups invisible to them. Reading still merges public +
// any legacy private items, so existing private entries keep working.
return if (relayGroupList == null) {
SimpleGroupListEvent.create(
publicGroups = listOf(group),
signer = signer,
)
} else {
SimpleGroupListEvent.add(
earlierVersion = relayGroupList,
group = group,
isPrivate = false,
signer = signer,
)
}
}
suspend fun unfollow(channel: RelayGroupChannel): SimpleGroupListEvent? {
val relayGroupList = getRelayGroupList() ?: return null
return SimpleGroupListEvent.remove(
earlierVersion = relayGroupList,
group = channel.toGroupTag(),
signer = signer,
)
}
init {
settings.relayGroupList()?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved relay group list")
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
cache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "RelayGroupList Collector Start")
getRelayGroupListFlow().collect { noteState ->
Log.d("AccountRegisterObservers") { "RelayGroupList for ${signer.pubKey}" }
(noteState.note.event as? SimpleGroupListEvent)?.let {
settings.updateRelayGroupListTo(it)
}
}
}
}
}
@@ -0,0 +1,60 @@
/*
* 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.nip29RelayGroups
/**
* A user's membership in a NIP-29 group, derived from the relay's own signed
* lists (kind 39001 admins / 39002 members). This is the relay's truth, not the
* client's kind-10009 intent.
*
* [PENDING] is a client-side, optimistic state: the user sent a join request
* (kind 9021) but the relay hasn't added them to the roster yet (a closed group
* awaiting approval, or an open group we haven't re-fetched). It is never read
* from a relay event.
*/
enum class RelayGroupMembership {
/** Holds the `admin` role — full control. */
ADMIN,
/** Holds the `moderator` role — can moderate. */
MODERATOR,
/** In the members list, no elevated role. */
MEMBER,
/** Join request sent, not yet admitted (optimistic, client-side only). */
PENDING,
/** Not in the roster. */
NONE,
;
/** True when the relay currently considers the user part of the group. */
fun isMember(): Boolean = this == ADMIN || this == MODERATOR || this == MEMBER
/** True when the user may moderate (remove users, delete messages, invite). */
fun canModerate(): Boolean = this == ADMIN || this == MODERATOR
companion object {
const val ROLE_ADMIN = "admin"
const val ROLE_MODERATOR = "moderator"
}
}
@@ -0,0 +1,41 @@
/*
* 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.nip29RelayGroups
/**
* How the user's joined NIP-29 relay groups are surfaced in the Messages tab.
*
* - [INLINE] (default): each joined channel is a row in the Messages list,
* tagged with a chip naming its host relay. Relaxed, everything-in-one-list.
* - [GROUPED]: the Messages list shows a row per host relay; tapping a relay
* opens its channel list. For treating each relay's groups as a set.
*/
enum class RelayGroupViewMode {
INLINE,
GROUPED,
;
companion object {
val DEFAULT = INLINE
fun fromName(name: String?): RelayGroupViewMode = entries.firstOrNull { it.name == name } ?: DEFAULT
}
}
@@ -223,7 +223,7 @@ class RichTextParser {
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
val allUrls = urlSet.withScheme + urlSet.withoutScheme + urlSet.emails + urlSet.bech32s + urlSet.relayUrls + urlSet.blossomUris
val allUrls = urlSet.withScheme + urlSet.withoutScheme + urlSet.emails + urlSet.bech32s + urlSet.relayUrls + urlSet.blossomUris + urlSet.groupLinks
val newContent = fixMissingSpaces(content, allUrls)
@@ -377,6 +377,10 @@ class RichTextParser {
if (urls.bech32s.contains(word)) return BechSegment(word)
// Checked before relayUrls: a group link `wss://relay'groupId` embeds a relay URL,
// so it must win over the bare relay-URL interpretation.
if (urls.groupLinks.contains(word)) return RelayGroupLinkSegment(word)
if (urls.relayUrls.contains(word)) return RelayUrlSegment(word)
if (urls.blossomUris.contains(word)) return BlossomUriSegment(word)

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