diff --git a/amethyst/plans/2026-07-07-nip29-armada-study.md b/amethyst/plans/2026-07-07-nip29-armada-study.md new file mode 100644 index 0000000000..4d8e0aa3cf --- /dev/null +++ b/amethyst/plans/2026-07-07-nip29-armada-study.md @@ -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", ""]` +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", , …]` 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//`. + +> 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//?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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c853fc7cc1..b1e132d3e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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(latestHashtagListStr) } val latestGeohashList = async { parseEventOrNull(latestGeohashListStr) } val latestEphemeralList = async { parseEventOrNull(latestEphemeralListStr) } + val latestRelayGroupList = async { parseEventOrNull(latestRelayGroupListStr) } val latestTrustProviderList = async { parseEventOrNull(latestTrustProviderListStr) } val latestPaymentTargets = async { parseEventOrNull(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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2a5f8b5d84..b8f6eb22d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = topNavFilterFlow(settings.defaultPicturesFollowList) val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow + val liveRelayGroupsDiscoveryFollowLists: StateFlow = topNavFilterFlow(settings.defaultRelayGroupsDiscoveryFollowList) + val liveRelayGroupsDiscoveryFollowListsPerRelay = OutboxLoaderState(liveRelayGroupsDiscoveryFollowLists, cache, scope).flow + val liveNappletsFollowLists: StateFlow = 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 = emptyList(), + geohashes: List = 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 = + 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, + ) { + 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 = emptyList(), + geohashes: List = 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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index ea25cf75ea..002cd04e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 = MutableStateFlow(TopFilter.AllFollows), val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultAppRecommendationsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val clinkDebitWallets: MutableStateFlow> = 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 = MutableStateFlow(true), val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST), + val relayGroupViewMode: MutableStateFlow = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index cfadfd38fb..09bbdc0e5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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() val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() + val relayGroupChannels = LargeCache() 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 = 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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt index 308688d6c3..854e7147ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt @@ -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 = 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, + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt index 0e69c58954..07a265e947 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt @@ -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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt new file mode 100644 index 0000000000..779efc2079 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 1d79f9f370..0d63313e06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 849c527b73..28b4f7b4f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 70ac43cff2..74fee9cd74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 9d7c0cdfe6..c8f58630ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -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 = - key.account.notificationRelays.flow.value.flatMap { - filterSummaryNotificationsToPubkey( - relay = it, - pubkey = user(key).pubkeyHex, - since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(), - ) + - filterNotificationsToPubkey( + ): List { + 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>() @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index aeef3e5da1..05f95c095d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -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, + since: Long?, +): List { + 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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 66d837e82d..caf3ea93d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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 `'[?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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt new file mode 100644 index 0000000000..4e25814a33 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt @@ -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 (`'[?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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 125d032488..e87ede251a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt new file mode 100644 index 0000000000..96e3bf48ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -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 (`'[?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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 6114b369ed..183a5395ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 68430b7104..4611f09a70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index bc929ff893..0be3941fad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { PublicChatsScreen(accountViewModel, nav) } + composableFromEnd { RelayGroupDiscoveryScreen(accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } @@ -463,6 +473,7 @@ fun BuildNavigation( composableFromEnd { ComposeSettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { MessagesSettingsScreen(accountViewModel, nav) } composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } @@ -555,6 +566,68 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupChatScreen( + id = it.id, + relayUrl = it.relayUrl, + draftId = it.draftId, + replyToId = it.replyTo, + inviteCode = it.inviteCode, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + RelayGroupChannelListScreen( + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + RelayGroupMembersScreen( + id = it.id, + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + RelayGroupThreadsScreen( + id = it.id, + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + RelayGroupCreateScreen( + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + RelayGroupEditScreen( + id = it.id, + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + RelayGroupBrowseScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index aab1307013..aeeb9077c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -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 = 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.NESTS, NavBarItem.COMMUNITIES, NavBarItem.PUBLIC_CHATS, + NavBarItem.RELAY_GROUPS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SOFTWARE_APPS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 48fb62e212..579b779d5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 928af18b7a..e16b15383d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 3a9df00e63..f680c7183e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 9a2b7b26af..80a1ce25b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 96ffc4ec7e..a3a3ec542a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0d3fb5d157..9eb163a241 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, + geohashes: List, + ) = 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, + ) = 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, + geohashes: List, + ) = launchSigner { + account.editRelayGroupMetadata( + channel, + name, + about, + picture, + isPrivate, + isClosed, + isHidden, + isRestricted, + hashtags, + geohashes, + ) + } + fun follow(users: List) = 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 loadParticipants( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 8052f08ab3..dab8a5916d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt index 70fea4c935..a69c2c4db4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt index 0daf59b228..65ea720edc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToRelayGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToRelayGroup.kt new file mode 100644 index 0000000000..303c5d3351 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToRelayGroup.kt @@ -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 = + 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, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt new file mode 100644 index 0000000000..a9c208ca76 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt @@ -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 = + 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, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToRelayGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToRelayGroup.kt new file mode 100644 index 0000000000..c2ead3f9a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToRelayGroup.kt @@ -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 = + 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, + ), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt new file mode 100644 index 0000000000..e89cd689e1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -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)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt new file mode 100644 index 0000000000..c7eabc5392 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt @@ -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)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/LoadRelayGroupChannel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/LoadRelayGroupChannel.kt new file mode 100644 index 0000000000..0704bd9195 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/LoadRelayGroupChannel.kt @@ -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) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt new file mode 100644 index 0000000000..fbbb7cbe18 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt @@ -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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt new file mode 100644 index 0000000000..6821e1ba79 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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(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), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt new file mode 100644 index 0000000000..b84c48923e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt new file mode 100644 index 0000000000..8e3b894cd0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt @@ -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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt new file mode 100644 index 0000000000..d1f17c82f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -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, + 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, + 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, + 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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt new file mode 100644 index 0000000000..70b810d053 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt new file mode 100644 index 0000000000..66dc72dd20 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -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(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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt new file mode 100644 index 0000000000..5dc7735f39 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -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(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 = + 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 = + 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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt new file mode 100644 index 0000000000..e3068e3e06 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt new file mode 100644 index 0000000000..d38d971d05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt new file mode 100644 index 0000000000..186be5f1f5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/WarmJoinedRelayGroupNip11.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/WarmJoinedRelayGroupNip11.kt new file mode 100644 index 0000000000..364aeba052 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/WarmJoinedRelayGroupNip11.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt new file mode 100644 index 0000000000..8b67b05bf0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt @@ -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 = + 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() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt new file mode 100644 index 0000000000..6a060f9806 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt @@ -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() { + /** + * 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 = + account.relayGroupList.liveRelayGroupList.value + .mapNotNullTo(HashSet()) { tag -> + RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { GroupId(tag.groupId, it) } + } + + private fun isMyGroup( + channel: RelayGroupChannel, + joined: Set, + ): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember() + + private fun constraints(): Map = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() + + override fun feed(): List { + 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 { + val joined = joinedGroupIds() + val notes = HashSet() + // 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): Set { + 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, + ): 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): List { + // 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] } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt new file mode 100644 index 0000000000..0db5b6bc3e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt @@ -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() { + 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, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupMyJoinedGroupsQueryState, + since: SincePerRelayMap?, + ): List? { + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsSubscription.kt new file mode 100644 index 0000000000..6206438d03 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedFilterAssembler.kt new file mode 100644 index 0000000000..0bdc00b095 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedFilterAssembler.kt @@ -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() { + 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, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupThreadFeedQueryState, + since: SincePerRelayMap?, + ): List { + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedSubscription.kt new file mode 100644 index 0000000000..3272bc691d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupFilterAssembler.kt new file mode 100644 index 0000000000..5aff994a54 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupFilterAssembler.kt @@ -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() { + 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, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupWarmupQueryState, + since: SincePerRelayMap?, + ): List { + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupSubscription.kt new file mode 100644 index 0000000000..fe8d9aafd8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt new file mode 100644 index 0000000000..ca4990de21 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt @@ -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 = + 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() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt new file mode 100644 index 0000000000..5dde33785b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt @@ -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() { + val group = + listOf( + RelayGroupsDiscoverySubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..395e732d5e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt new file mode 100644 index 0000000000..9bc0a409dc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt @@ -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, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupsDiscoveryQueryState, + since: SincePerRelayMap?, + ): List { + 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:` 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()) { it.authors.orEmpty() } to feedSettings.set.keys + is AuthorsTopNavPerRelayFilterSet -> + feedSettings.set.values.flatMapTo(HashSet()) { 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 = + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt new file mode 100644 index 0000000000..c09d8174eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt @@ -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() { + 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, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupsOnRelayQueryState, + since: SincePerRelayMap?, + ): List = + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt new file mode 100644 index 0000000000..f97122929f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt new file mode 100644 index 0000000000..812a427e61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt @@ -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> = + 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:}` (metadata included); + * 2. a follow is an admin/member → `{kinds:[39001,39002], #p:}` (standard p-tag filter); + * 3. metadata backfill for the groups (2) surfaced → `{kinds:[39000], #d:}`, 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, + since: Long? = null, + cachedChannels: List = LocalCache.getRelayGroupChannelsOnRelay(relay), +): List { + 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 { + 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 { + 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(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt new file mode 100644 index 0000000000..6d4050bcdc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt @@ -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 { + 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 { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set.flatMap { + filterRelayGroupsDirectory( + relay = it.key, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt new file mode 100644 index 0000000000..64e5295a7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt @@ -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 { + 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)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt new file mode 100644 index 0000000000..f0c140077b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt @@ -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, + since: Long?, +): List { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt new file mode 100644 index 0000000000..908337a8fc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt @@ -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, + since: Long? = null, +): List = + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt new file mode 100644 index 0000000000..7974bd50bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt @@ -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 = + filterRelayGroupsDirectory( + relay = relays.relayUrl, + since = since?.get(relays.relayUrl)?.time ?: defaultSince, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt new file mode 100644 index 0000000000..41cd15fbbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt @@ -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 = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RELAY_GROUP_DISCOVERY_KINDS, + limit = 500, + since = since, + ), + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt new file mode 100644 index 0000000000..144b6061ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt @@ -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 { + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 142c025d54..f870b0595b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -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() + 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index 10f0ec9639..d9eacb8d29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 9633847b23..e54a7e3732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 292f599baf..613bb9917f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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, + account: Account, + ): MutableMap { + 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() + 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() + 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() + 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() + newestPerRelay.forEach { (relay, note) -> result[relay.url] = RelayGroupServerRoomNote(relay, note) } + result + } + } + } + private fun filterRelevantPrivateMessages( newItems: Set, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt new file mode 100644 index 0000000000..f0b8e8a26e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt @@ -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}" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index f1d6644a98..805aaaba77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -119,12 +119,13 @@ fun MessagesPager( tabs: List, accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { HorizontalPager( state = pagerState, userScrollEnabled = true, modifier = - Modifier.zonedDrawerSwipe( + modifier.zonedDrawerSwipe( pagerState = pagerState, openDrawer = nav::openDrawer, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 2be18d3f60..3b53a22c97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -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(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 53f497fc52..2cf453a10c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 0f1336a672..c0b698c19b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 0aa6359eb9..f623c7f489 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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()) 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, + ) + + 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt new file mode 100644 index 0000000000..edf13e06fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index b6aab37179..3ace415fd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -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), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bd4ffe9016..f275053e00 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1929,6 +1929,110 @@ Public Group New Public or Private Group + Relay Groups + Inline + By relay + Show each group as its own conversation, mixed in with your chats. + Collapse each relay\'s groups into a single row, placed at its newest message. + NIP-29 group display + Relay groups + Messages + messages, chats, groups, nip-29, relay, inline, dm + Create a group + 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. + Group name + Topic (optional) + Private (members-only read) + Invite only + Create + Invite people + Share this code so people can join this group. + Creating an invite… + Invite code copied + Invite code (for this invite-only group): + Join group + Invite code + This group is invite-only. Enter the code you were given. + Join + Requested + Admin + Moderator + Member + Members + Make admin + Make moderator + Remove role + Remove from group + Remove %1$s from this group? They will lose access until re-added or re-invited. + Edit group + Group name + Topic (optional) + Private (members-only read) + Invite only + Save + Members + Edit group + Threads + Open group + No groups on this relay yet. + Preparing invite… + Private + Invite-only + Enter a valid relay URL (wss://…). + Name + Description + Group picture + Add photo + Change photo + Discovery + Help people find this group. Topics and a location surface it under the matching discovery filters (if the host relay supports them). + Topics + bitcoin, nostr, art + Location (geohash) + u0nd + Permissions + Private + Only members can read messages. + Invite-only + People must be invited or approved to join. + Members-only posting + Only members can post messages. + Unlisted + Hide this group from the relay\'s public directory. + Looking for groups across your relays… + No groups found for this filter yet. + Favorite this relay + No threads yet. Start one with the + button. + New thread + Untitled + Title + Write something… + Post + Find groups + Browse the groups hosted on a relay. Paste a relay address, or pick one below. + Relay address + Browse + Relays you\'re on + Popular relays + No messages yet + Join this group to send messages. + This group is invite-only — you need an invite to post. + + %1$d member + %1$d members + + + %1$d message + %1$d messages + + + %1$d+ message + %1$d+ messages + + + %1$d person you follow + %1$d people you follow + Private To Subject diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt new file mode 100644 index 0000000000..09dd71b292 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt @@ -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>, + adminPubkeys: List = emptyList(), + memberPubkeys: List = 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) + } +} diff --git a/cli/README.md b/cli/README.md index fa900daf0b..7b9628ac97 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index a6fdb62a10..56f2bb0514 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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* diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index e74998b91d..22c849df1e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -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. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index c9aa8c2461..786f06dfbe 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt new file mode 100644 index 0000000000..1e880953b1 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -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, + ): Int = + route( + "relaygroup", + tail, + "relaygroup …", + 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, + ): 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, + ): 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, + ): 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 ` → publishes a kind-9 chat with an `h` tag. */ + private suspend fun message( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val text = args.positionalOrNull(2) ?: return Output.error("bad_args", "relaygroup message RELAY GROUP_ID ") + if (text.isBlank()) return Output.error("bad_args", "message text must not be blank") + return publishScoped(dataDir, rest, "relaygroup message RELAY GROUP_ID ") { _, 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() + .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 = + 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, + usage: String, + build: (relay: NormalizedRelayUrl, groupId: String, args: Args) -> EventTemplate, +): 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 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt new file mode 100644 index 0000000000..02e72eae81 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -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, + ): 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() + .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, + ): 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, + ): 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, + ): 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 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt new file mode 100644 index 0000000000..af7441e3d4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt @@ -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, + ): 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() + .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, + ): 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() + .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, + ): 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().maxByOrNull { it.createdAt } + val admins = events.filterIsInstance().maxByOrNull { it.createdAt }?.admins() ?: emptyList() + val members = events.filterIsInstance().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(), + ) +} diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 7f43ea55a5..b2a0f61f7a 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -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/ diff --git a/cli/tests/relaygroup/relaygroup-headless.sh b/cli/tests/relaygroup/relaygroup-headless.sh new file mode 100755 index 0000000000..b8a9121d8f --- /dev/null +++ b/cli/tests/relaygroup/relaygroup-headless.sh @@ -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 "\t"; any fail row → nonzero exit. +awk -F'\t' '$2=="fail"{f=1} END{exit f}' "$RESULTS_FILE" && exit 0 || exit 1 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/GroupDiscoveryConstraint.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/GroupDiscoveryConstraint.kt new file mode 100644 index 0000000000..662830dedb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/GroupDiscoveryConstraint.kt @@ -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, + ) : 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, + ) : 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, + ) : 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 { + override fun matches(channel: RelayGroupChannel) = constraints.any { it.matches(channel) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt new file mode 100644 index 0000000000..4a05bfa61c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -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 = emptySet() + private set + private var membersUpdatedAt: Long = 0 + + /** Relay-signed admins with their roles (kind 39001). */ + var admins: List = 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 = 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() + private val _threads = MutableStateFlow>(emptyList()) + val threads: StateFlow> = _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): List { + 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()}" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionCache.kt new file mode 100644 index 0000000000..393b33f9f3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionCache.kt @@ -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(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() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt new file mode 100644 index 0000000000..45d6fc21bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt @@ -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 = relayGroupListNote.flow().metadata.stateFlow + + fun getRelayGroupList(): SimpleGroupListEvent? = relayGroupListNote.event as? SimpleGroupListEvent + + suspend fun relayGroupListWithBackup(note: Note): Set { + val event = note.event as? SimpleGroupListEvent ?: settings.relayGroupList() + return event?.let { decryptionCache.groupSet(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveRelayGroupList: StateFlow> = + 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> = + 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) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt new file mode 100644 index 0000000000..8a4e55ca33 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt @@ -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" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupViewMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupViewMode.kt new file mode 100644 index 0000000000..9855e4ffac --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupViewMode.kt @@ -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 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index bb6ddb4430..8a5dc558b6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 0c5e7c062a..3e8a2a75f6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -155,6 +155,15 @@ class RelayUrlSegment( segment: String, ) : Segment(segment) +/** + * A NIP-29 group invite link in the `'[?code=]` form (Wisp/0xchat). + * Rendered as a tappable chip that opens the group; [segmentText] is the whole literal. + */ +@Immutable +class RelayGroupLinkSegment( + segment: String, +) : Segment(segment) + @Immutable class BlossomUriSegment( segment: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt index 04078271c9..d4650375c1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.startsWithAny @@ -36,6 +37,11 @@ class Urls( val bech32s: Set = emptySet(), val relayUrls: Set = emptySet(), val blossomUris: Set = emptySet(), + // NIP-29 group invite links in the `'[?code=]` form (Wisp/0xchat). + // The literal, whole-span text so fixMissingSpaces can keep it atomic and wordIdentifier + // can match it. See [UrlParser.parseValidUrls] for how these are recovered from the + // relay URLs the detector already found. + val groupLinks: Set = emptySet(), ) val httpScheme = listOf(DualCase("http")) @@ -82,6 +88,7 @@ class UrlParser { val bech32 = mutableSetOf() val relays = mutableSetOf() val blossom = mutableSetOf() + val groupLinks = mutableSetOf() urls.forEach { url -> try { @@ -94,6 +101,12 @@ class UrlParser { bech32.add(url.originalUrl) } else if (url.originalUrl.startsWithAny(websocketScheme)) { relays.add(url.originalUrl) + // A relay URL glued to `'` in the source is a NIP-29 group + // invite link. The detector always ends the host at the apostrophe + // (host names can't contain `'`), so we recover the whole + // `'[?code=]` span here rather than teaching the + // URL grammar about it — see the peek in collectGroupLinks. + collectGroupLinks(content, url.originalUrl, groupLinks) } else if (url.originalUrl.startsWithAny(blossomScheme)) { blossom.add(url.originalUrl) } else { @@ -123,6 +136,33 @@ class UrlParser { bech32s = bech32, relayUrls = relays, blossomUris = blossom, + groupLinks = groupLinks, ) } + + /** + * For every occurrence of [relayUrl] in [content], check whether it is immediately + * followed by `'[?code=]` and, if so, add the full literal span to + * [out]. This runs only for the handful of websocket URLs the detector already found, + * so a note with no relay link costs nothing extra. + */ + private fun collectGroupLinks( + content: String, + relayUrl: String, + out: MutableSet, + ) { + var from = 0 + while (true) { + val idx = content.indexOf(relayUrl, from) + if (idx < 0) break + val end = idx + relayUrl.length + from = end + if (end < content.length && content[end] == '\'') { + val suffixLen = GroupInviteLink.suffixLength(content, end + 1) + if (suffixLen > 0) { + out.add(content.substring(idx, end + 1 + suffixLen)) + } + } + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 97621650b2..fc2042c2be 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -164,7 +164,10 @@ class RobohashAssembler { isLightTheme: Boolean, ): ImageVector { val hash = - if (Hex.isHex(msg) && msg.length > 10) { + // The assembler reads up to hash[10], so the decoded array must hold ≥ 11 bytes + // (22 hex chars). A shorter hex string — e.g. a 16-char NIP-29 group id (8 bytes) — + // would index out of bounds, so fall back to sha256 (32 bytes) for anything smaller. + if (Hex.isHex(msg) && msg.length >= 22) { Hex.decode(msg) } else { Log.w("Robohash") { "$msg is not a hex" } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt new file mode 100644 index 0000000000..2d34e019d3 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt @@ -0,0 +1,168 @@ +/* + * 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.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * End-to-end coverage for inline NIP-29 group invite links (`'[?code]`) + * flowing through the full parse pipeline: UrlParser detection → fixMissingSpaces (must + * keep the span atomic) → wordIdentifier (must emit [RelayGroupLinkSegment]). Also proves + * the change doesn't disturb plain relay URLs or ordinary prose apostrophes. + */ +class RichTextParserGroupLinkTest { + private fun groupSegmentsOf(text: String): List = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + .filterIsInstance() + + @Test + fun detectsBareGroupLink() { + val links = UrlParser().parseValidUrls("join wss://groups.0xchat.com'abc123 now") + assertEquals(setOf("wss://groups.0xchat.com'abc123"), links.groupLinks) + } + + @Test + fun detectsGroupLinkWithCode() { + val links = UrlParser().parseValidUrls("join wss://chat.wisp.talk'grp?code=xyz789 please") + assertEquals(setOf("wss://chat.wisp.talk'grp?code=xyz789"), links.groupLinks) + } + + @Test + fun rendersGroupLinkInTheMiddleOfAPost() { + // The whole `'` must survive fixMissingSpaces as ONE token — + // no space wedged in at the apostrophe — and come out as a group-link segment. + val segs = groupSegmentsOf("come chat at wss://groups.0xchat.com'abc123 with us") + assertEquals(1, segs.size) + assertEquals("wss://groups.0xchat.com'abc123", segs.first().segmentText) + } + + @Test + fun rendersGroupLinkGluedToText() { + // No spaces around the link at all — fixMissingSpaces must still split it out whole. + val segs = groupSegmentsOf("here:wss://groups.0xchat.com'abc123!") + assertEquals(1, segs.size) + assertEquals("wss://groups.0xchat.com'abc123", segs.first().segmentText) + } + + @Test + fun rendersGroupLinkWithCode() { + val segs = groupSegmentsOf("invite: wss://chat.wisp.talk'grp?code=xyz789 join up") + assertEquals(1, segs.size) + assertEquals("wss://chat.wisp.talk'grp?code=xyz789", segs.first().segmentText) + } + + @Test + fun plainRelayUrlIsNotAGroupLink() { + val text = "my relay is wss://relay.damus.io for now" + val links = UrlParser().parseValidUrls(text) + assertTrue(links.groupLinks.isEmpty()) + assertTrue(links.relayUrls.contains("wss://relay.damus.io")) + assertTrue(groupSegmentsOf(text).isEmpty()) + } + + @Test + fun proseApostropheAfterHttpUrlIsNotAGroupLink() { + // A possessive after a normal URL must not be mistaken for a group link (and it + // isn't a relay URL either, so this exercises the "only relay URLs are peeked" path). + val links = UrlParser().parseValidUrls("read example.com's blog") + assertTrue(links.groupLinks.isEmpty()) + } + + @Test + fun relayUrlWithTrailingApostropheButNoGroupIdIsNotALink() { + val links = UrlParser().parseValidUrls("odd wss://relay.damus.io' end") + assertTrue(links.groupLinks.isEmpty()) + assertNull(groupSegmentsOf("odd wss://relay.damus.io' end").firstOrNull()) + } + + // ---- weird apostrophe placements after non-relay URLs: none may become a group link ---- + + @Test + fun possessiveAfterRelayUrlIsNotAGroupLink() { + // "wss://relay.damus.io's uptime" — the possessive `'s` must not linkify group "s". + val text = "how is wss://relay.damus.io's uptime today" + val links = UrlParser().parseValidUrls(text) + assertTrue(links.groupLinks.isEmpty()) + assertTrue(links.relayUrls.contains("wss://relay.damus.io")) + assertTrue(groupSegmentsOf(text).isEmpty()) + } + + @Test + fun apostropheAfterHttpUrlIsNotAGroupLink() { + val links = UrlParser().parseValidUrls("see https://example.com'abc123 here") + assertTrue(links.groupLinks.isEmpty()) + assertTrue(links.withScheme.contains("https://example.com")) + assertTrue(groupSegmentsOf("see https://example.com'abc123 here").isEmpty()) + } + + @Test + fun apostropheAfterNostrUriIsNotAGroupLink() { + val text = "ping nostr:npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9'abc there" + val links = UrlParser().parseValidUrls(text) + assertTrue(links.groupLinks.isEmpty()) + } + + @Test + fun apostropheAfterBlossomUriIsNotAGroupLink() { + val text = "file blossom://b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553'abc" + assertTrue(UrlParser().parseValidUrls(text).groupLinks.isEmpty()) + } + + @Test + fun apostropheAfterEmailIsNotAGroupLink() { + val links = UrlParser().parseValidUrls("mail vitor@example.com'abc please") + assertTrue(links.groupLinks.isEmpty()) + } + + @Test + fun insecureWsSchemeAlsoLinkifies() { + // `ws://` (not just `wss://`) is a relay scheme and must peek the same way. + val links = UrlParser().parseValidUrls("dev ws://relay.example.com'abc123 test") + assertEquals(setOf("ws://relay.example.com'abc123"), links.groupLinks) + } + + @Test + fun secondApostropheEndsTheGroupId() { + // `wss://relay.example.com'abc'def` — the group id is `abc`; `'def` is left as text. + val segs = groupSegmentsOf("go wss://relay.example.com'abc'def now") + assertEquals(1, segs.size) + assertEquals("wss://relay.example.com'abc", segs.first().segmentText) + } + + @Test + fun multipleGroupLinksInOnePostAreAllDetected() { + val links = + UrlParser().parseValidUrls( + "two groups wss://groups.0xchat.com'aaaa and wss://chat.wisp.talk'bbbb here", + ) + assertEquals( + setOf("wss://groups.0xchat.com'aaaa", "wss://chat.wisp.talk'bbbb"), + links.groupLinks, + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt new file mode 100644 index 0000000000..d346cfaabf --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -0,0 +1,215 @@ +/* + * 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.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 com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Client-side derivations in [RelayGroupChannel] — the relay-signed roster is the + * source of truth, so this exercises how the channel folds 39000/39001/39002 into + * a membership answer and a member count, and that the `createdAt` supersede + * guards drop stale events. Protocol parsing itself is covered by Quartz's + * Nip29ArmadaInteropTest; here we test the fold, not the parse. + */ +class RelayGroupChannelTest { + private val relaySelf = "aa".repeat(32) + private val alice = "bb".repeat(32) + private val bob = "cc".repeat(32) + private val carol = "dd".repeat(32) + private val gid = "0123456789abcdef" + private val relay = RelayUrlNormalizer.normalize("wss://relay.example.com") + + private fun channel() = RelayGroupChannel(GroupId(gid, relay)) + + private fun metadata( + createdAt: Long, + flags: List = emptyList(), + name: String? = null, + ): GroupMetadataEvent { + val tags = + buildList { + add(arrayOf("d", gid)) + name?.let { add(arrayOf("name", it)) } + flags.forEach { add(arrayOf(it)) } + }.toTypedArray() + return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupMetadataEvent.KIND, tags, "", "22".repeat(64)) as GroupMetadataEvent + } + + private fun members( + createdAt: Long, + vararg pubkeys: String, + ): GroupMembersEvent { + val tags = (listOf(arrayOf("d", gid)) + pubkeys.map { arrayOf("p", it) }).toTypedArray() + return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupMembersEvent.KIND, tags, "", "22".repeat(64)) as GroupMembersEvent + } + + private fun admins( + createdAt: Long, + vararg entries: Pair>, + ): GroupAdminsEvent { + val tags = (listOf(arrayOf("d", gid)) + entries.map { (pk, roles) -> arrayOf("p", pk, *roles.toTypedArray()) }).toTypedArray() + return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupAdminsEvent.KIND, tags, "", "22".repeat(64)) as GroupAdminsEvent + } + + @Test + fun placeholderNoteCarriesChannelGathererAndIsStable() { + val c = channel() + val note = c.placeholderNote() + + // Stable, group-derived id and — crucially — the channel is registered as a gatherer so the + // Messages row renderer (ChatroomHeaderCompose) resolves the event-less note back to the + // group instead of blanking it. + assertEquals("relaygroup-empty-${c.groupId.toKey()}", note.idHex) + assertTrue(note.inGatherers?.any { it === c } == true) + // Cached: same instance on repeat so equality-based feed diffing keeps one stable row. + assertTrue(c.placeholderNote() === note) + } + + @Test + fun membershipFromRolesAndMembers() { + val c = channel() + c.updateAdmins(admins(100, alice to listOf("admin"), bob to listOf("moderator"))) + c.updateMembers(members(100, alice, bob, carol)) + + assertEquals(RelayGroupMembership.ADMIN, c.membershipOf(alice)) + assertEquals(RelayGroupMembership.MODERATOR, c.membershipOf(bob)) + // carol is only in the members list — a plain member. + assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(carol)) + // unknown key. + assertEquals(RelayGroupMembership.NONE, c.membershipOf("ee".repeat(32))) + } + + @Test + fun adminInAdminsListButAbsentFromMembersStillCounts() { + // Relays commonly list admins in 39001 only, not 39002. The admin must + // still resolve as a member, not NONE. + val c = channel() + c.updateAdmins(admins(100, alice to listOf("admin"))) + c.updateMembers(members(100, bob)) + + assertEquals(RelayGroupMembership.ADMIN, c.membershipOf(alice)) + assertTrue(c.membershipOf(alice).isMember()) + } + + @Test + fun adminWithUnknownRoleStillModerates() { + // Presence in the 39001 admins list is the moderation signal; an + // unrecognized (or empty) role label must not demote to plain MEMBER. + val c = channel() + c.updateAdmins(admins(100, alice to listOf("ceo"), bob to emptyList())) + assertEquals(RelayGroupMembership.MODERATOR, c.membershipOf(alice)) + assertEquals(RelayGroupMembership.MODERATOR, c.membershipOf(bob)) + assertTrue(c.membershipOf(alice).canModerate()) + } + + @Test + fun equalCreatedAtDoesNotResupersede() { + val c = channel() + c.updateMembers(members(100, alice, bob)) + // A second 39002 with the SAME createdAt but fewer members must not win. + c.updateMembers(members(100, alice)) + assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(bob)) + assertEquals(2, c.memberCount()) + } + + @Test + fun memberCountDedupesAdminsAndMembers() { + val c = channel() + // alice appears in BOTH lists; she must be counted once. + c.updateAdmins(admins(100, alice to listOf("admin"))) + c.updateMembers(members(100, alice, bob, carol)) + assertEquals(3, c.memberCount()) + } + + @Test + fun staleMembersEventDoesNotSupersede() { + val c = channel() + c.updateMembers(members(200, alice, bob)) + // An OLDER roster arrives late (out-of-order relay delivery) — ignore it. + c.updateMembers(members(100, alice)) + assertEquals(2, c.memberCount()) + assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(bob)) + } + + @Test + fun newerMembersEventReplaces() { + val c = channel() + c.updateMembers(members(100, alice)) + c.updateMembers(members(200, alice, bob)) + assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(bob)) + assertEquals(2, c.memberCount()) + } + + @Test + fun staleMetadataDoesNotSupersedeFlagsOrName() { + val c = channel() + c.updateGroupInfo(metadata(200, flags = listOf("private", "closed"), name = "Newer")) + c.updateGroupInfo(metadata(100, flags = emptyList(), name = "Older")) + + assertEquals("Newer", c.toBestDisplayName()) + assertTrue(c.isPrivate()) + assertTrue(c.isClosed()) + } + + @Test + fun flagsReflectLatestMetadata() { + val c = channel() + assertFalse(c.isPrivate()) + assertFalse(c.isClosed()) + c.updateGroupInfo(metadata(100, flags = listOf("private"))) + assertTrue(c.isPrivate()) + assertFalse(c.isClosed()) + } + + @Test + fun displayNameFallsBackToGroupIdWithoutMetadata() { + assertEquals(gid, channel().toBestDisplayName()) + } + + @Test + fun threadsDedupePublishToFlowAndRemove() { + val c = channel() + val t1 = Note("11".repeat(32)) + val t2 = Note("22".repeat(32)) + + c.addThread(t1) + c.addThread(t2) + c.addThread(t1) // duplicate id — must not double-count + + assertEquals(2, c.threadCount()) + assertEquals(2, c.threads.value.size) + assertTrue(c.threads.value.any { it.idHex == t1.idHex }) + + c.removeThread(t1) + assertEquals(1, c.threadCount()) + assertTrue(c.threads.value.none { it.idHex == t1.idHex }) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionTest.kt new file mode 100644 index 0000000000..30acd5af44 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionTest.kt @@ -0,0 +1,104 @@ +/* + * 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.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The join/leave paths store groups as NIP-44 private items in the kind-10009 + * list (encrypted to self). This drives the exact `create`/`add`/`remove` calls + * that [RelayGroupListState.follow] / `unfollow` delegate to, then reads them + * back through [RelayGroupListDecryptionCache] — proving a followed group + * survives the encrypt→sign→decrypt round-trip and that unfollow removes it. + */ +class RelayGroupListDecryptionTest { + private val signer = NostrSignerInternal(KeyPair()) + private val cache = RelayGroupListDecryptionCache(signer) + + private val relay = "wss://relay.example.com" + private val groupA = GroupTag("aaaa", relay, "Alpha") + private val groupB = GroupTag("bbbb", relay, "Beta") + + private fun keysIn(event: SimpleGroupListEvent) = runBlocking { cache.groupSet(event).map { it.groupId }.toSet() } + + @Test + fun publicGroupIsAPlainTagAndStillReadThroughTheCache() = + runBlocking { + // follow() now writes public tags (interop with Flotilla/nostrord). A + // public group must appear as a plain `["group", …]` tag AND still be + // returned by the same merged read path used for private items. + val list = SimpleGroupListEvent.create(publicGroups = listOf(groupA), signer = signer) + + assertTrue(list.publicGroups().any { it.groupId == "aaaa" }) + assertEquals(setOf("aaaa"), keysIn(list)) + } + + @Test + fun readMergesPublicAndPrivateItems() = + runBlocking { + // A mixed list (a legacy private item + a new public one) reads as both, + // so switching follow() to public doesn't strip previously-private groups. + val list = SimpleGroupListEvent.create(publicGroups = listOf(groupA), privateGroups = listOf(groupB), signer = signer) + assertEquals(setOf("aaaa", "bbbb"), keysIn(list)) + } + + @Test + fun followedPrivateGroupRoundTripsThroughDecryption() = + runBlocking { + val list = SimpleGroupListEvent.create(privateGroups = listOf(groupA), signer = signer) + + // The group is NOT a public tag — it lives encrypted in content. + assertTrue(list.publicGroups().isEmpty()) + assertEquals(setOf("aaaa"), keysIn(list)) + } + + @Test + fun addingASecondGroupKeepsBoth() = + runBlocking { + val first = SimpleGroupListEvent.create(privateGroups = listOf(groupA), signer = signer) + val second = SimpleGroupListEvent.add(first, groupB, isPrivate = true, signer = signer) + assertEquals(setOf("aaaa", "bbbb"), keysIn(second)) + } + + @Test + fun unfollowRemovesOnlyThatGroup() = + runBlocking { + val both = + SimpleGroupListEvent.add( + SimpleGroupListEvent.create(privateGroups = listOf(groupA), signer = signer), + groupB, + isPrivate = true, + signer = signer, + ) + val afterRemove = SimpleGroupListEvent.remove(both, groupA, signer = signer) + val keys = keysIn(afterRemove) + assertFalse("aaaa" in keys) + assertTrue("bbbb" in keys) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index f12a7339d7..25d3a9bf8e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -96,6 +96,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent 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 @@ -612,6 +613,7 @@ object KindNames { GroupAdminsEvent.KIND to KindName("Group Admins", "29"), GroupMembersEvent.KIND to KindName("Group Members", "29"), SupportedRolesEvent.KIND to KindName("Group Roles", "29"), + GroupParticipantsEvent.KIND to KindName("Group Participants", "29"), MediaStarterPackEvent.KIND to KindName("Media Starter Pack", "51"), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt index 89e4accaec..0190139377 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt @@ -97,6 +97,13 @@ fun TagArray.anyTagWithValueStartingWith( */ fun TagArray.hasTagWithContent(tagName: String) = this.fastAny { it.size > 1 && it[0] == tagName } +/** + * Returns `true` if at least one tag has the given name, regardless of whether it + * carries a value. Use this for presence/flag tags like `["private"]` or `["closed"]` + * (NIP-29 status flags) where the tag name alone is the signal. + */ +fun TagArray.hasTagName(tagName: String) = this.fastAny { it.isNotEmpty() && it[0] == tagName } + /** * Returns a list containing only the non-null results of applying the tag value to the given [transform] function * to each tag that matches the [tagName] diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt index 50aef064d6..755113f590 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt @@ -47,7 +47,9 @@ data class NProfile( val hex = tlv.firstAsHex(TlvTypes.SPECIAL) ?: return null val relay = tlv.asStringList(TlvTypes.RELAY) ?: emptyList() - if (hex.isBlank()) return null + // x-only 32-byte pubkey only (64 hex chars). Reject a 33-byte compressed + // key or any other malformed length — see NPub.parse for why it matters. + if (hex.length != 64) return null return NProfile(hex, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt index 87c8b89c9e..939ee82bcf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NPub.kt @@ -32,7 +32,11 @@ data class NPub( ) : IPubKeyEntity { companion object { fun parse(bytes: ByteArray): NPub? { - if (bytes.isEmpty()) return null + // A Nostr pubkey is x-only, exactly 32 bytes. Reject anything else — + // notably a 33-byte COMPRESSED secp256k1 key (0x02/0x03 prefix) that some + // clients wrongly encode into an npub. Passing its 66-char hex through into + // a `p`/`q` tag gets the whole event rejected by strict relays (relay29). + if (bytes.size != 32) return null return NPub(bytes.toHexKey()) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupId.kt new file mode 100644 index 0000000000..f72fadc4c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupId.kt @@ -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.quartz.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +/** + * The identity of a NIP-29 relay-based group. A group is NOT global: its id is + * only unique within its host relay, so a group is always the pair of the host + * relay and the group id (the `h` tag on its events, the `d` tag on its kind + * 39000 metadata). Mirrors [com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId]. + */ +data class GroupId( + val id: String, + val relayUrl: NormalizedRelayUrl, +) : Comparable { + fun toKey() = "$id@${relayUrl.url}" + + fun toDisplayKey() = "$id@${relayUrl.displayUrl()}" + + override fun compareTo(other: GroupId): Int { + val result = id.compareTo(other.id) + return if (result == 0) { + relayUrl.url.compareTo(other.relayUrl.url) + } else { + result + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt new file mode 100644 index 0000000000..e2b62e8fc6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * A NIP-29 group invite link in the de-facto `'[?code=]` form + * emitted by Wisp and 0xchat — e.g. `wss://groups.0xchat.com'abc123?code=xyz`. + * + * This is NOT part of the NIP-29 spec (which shares a group as its kind-39000 `naddr`); + * it is an interop convenience some clients use. The apostrophe separates the host from + * the group id, and that is the one position a URL detector will never fold into the URL + * (DNS host names can't contain `'`), so these links must be recognised explicitly rather + * than through the generic URL parser. + */ +data class GroupInviteLink( + val relayUrl: NormalizedRelayUrl, + val groupId: String, + val code: String? = null, +) { + fun toGroupId() = GroupId(groupId, relayUrl) + + companion object { + // Group ids and invite codes are ASCII "word" characters in practice (relay29, + // Wisp and 0xchat all use lowercase alphanumerics; `_` is the default group id). + // Restricting the scan to this set is what keeps the inline linkifier from + // swallowing trailing prose punctuation into the link. + private fun isIdChar(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_' + + /** + * A single-character group id is only accepted when it is the relay-wide default + * group `_`. Every real group id from the reference clients (relay29 random ids, + * Wisp/0xchat 12+ chars) is longer, whereas a lone char after an apostrophe is + * almost always an English possessive/contraction glued to a bare relay URL + * (`wss://relay.damus.io's uptime`). Rejecting it kills that false positive without + * dropping any real link. + */ + private fun isAcceptableGroupId(id: String): Boolean = id.length >= 2 || id == "_" + + private const val CODE_PREFIX = "?code=" + + /** + * Reads the `[?code=]` portion that starts at [from] in [content] + * and returns its character length, or 0 when there is no valid group id there. + * + * The inline linkifier calls this immediately after the apostrophe that ended a + * detected relay URL — so it can keep the whole `'…` span atomic + * through the space-fixing / word-splitting pipeline without re-scanning the URL. + */ + fun suffixLength( + content: String, + from: Int, + ): Int { + var i = from + while (i < content.length && isIdChar(content[i])) i++ + if (i == from) return 0 // empty group id — not a link + if (!isAcceptableGroupId(content.substring(from, i))) return 0 // possessive/contraction guard + + // Optional `?code=`; only extend the span when a non-empty code follows. + if (content.startsWith(CODE_PREFIX, i)) { + var j = i + CODE_PREFIX.length + val codeStart = j + while (j < content.length && isIdChar(content[j])) j++ + if (j > codeStart) i = j + } + return i - from + } + + /** + * Parses a complete invite-link string into its parts. Returns null when there is + * no `'` separator, the host isn't a valid relay URL, or the group id is empty or + * contains characters outside the id charset. + */ + fun parse(link: String): GroupInviteLink? { + val apos = link.indexOf('\'') + if (apos <= 0) return null + + val relay = RelayUrlNormalizer.normalizeOrNull(link.substring(0, apos)) ?: return null + + val rest = link.substring(apos + 1) + val queryIdx = rest.indexOf('?') + val groupId = if (queryIdx < 0) rest else rest.substring(0, queryIdx) + if (!groupId.all { isIdChar(it) } || !isAcceptableGroupId(groupId)) return null + + val code = + if (queryIdx >= 0) { + rest + .substring(queryIdx + 1) + .split('&') + .firstOrNull { it.startsWith("code=") } + ?.removePrefix("code=") + ?.takeIf { it.isNotEmpty() } + } else { + null + } + + return GroupInviteLink(relay, groupId, code) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt new file mode 100644 index 0000000000..fd88ddf8b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt @@ -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.quartz.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +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 + +/* + * NIP-29 group scoping via the `h` tag. + * + * NIP-29 does not define new event kinds for content: a group chat message is a + * regular kind-9 [com.vitorpamplona.quartz.nipC7Chats.ChatEvent], a thread is a + * kind-11 [com.vitorpamplona.quartz.nip7DThreads.ThreadEvent], and reactions, + * polls, comments and deletions keep their own kinds. What makes any of them + * "belong" to a group is a single `["h", ""]` tag that tells the + * relay to route and authorize the write. These helpers add and read that tag on + * ANY event, so the existing carrier classes can be reused unchanged: + * + * ``` + * val msg = ChatEvent.build("gm") { hTag(groupId) } // kind 9 in a group + * val react = ReactionEvent.build("🔥", target) { hTag(gid) } // reaction in a group + * val gid = someEvent.groupId() // which group is this in? + * ``` + */ + +/** Adds the `["h", groupId]` NIP-29 group tag (deduplicated). */ +fun TagArrayBuilder.hTag(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) + +/** The group id (`h` tag value) that scopes this tag array to a NIP-29 group, or null. */ +fun TagArray.hTag(): String? = firstTagValue(GroupIdTag.TAG_NAME) + +/** The group id (`h` tag value) that scopes this event to a NIP-29 group, or null. */ +fun Event.groupId(): String? = tags.hTag() + +/** True when this event carries a NIP-29 group (`h`) tag. */ +fun Event.isGroupScoped(): Boolean = groupId() != null + +/** + * True when this group-scoped event is actual room *content* — a message someone + * posted — rather than metadata *about* content (a reaction, deletion, label, + * moderation action, …), which carries the same `h` tag but isn't a "message". + * + * Only content represents a room's latest message in list views: a kind-7 reaction + * to my group message is group-scoped too, so without this distinction it would be + * picked as the group's "last message" and render as a bogus, unopenable row on the + * Messages tab. Content kinds: 9 ([ChatEvent]), 1068 ([PollEvent]), 11 + * ([ThreadEvent]), 1111 ([CommentEvent]). + */ +fun Event.isGroupChatContent(): Boolean = this is ChatEvent || this is PollEvent || this is ThreadEvent || this is CommentEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index b15718f7c6..618c41fab1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -25,9 +25,13 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent +import com.vitorpamplona.quartz.nip01Core.core.hasTagName import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -51,18 +55,48 @@ class GroupMetadataEvent( fun picture() = tags.firstTagValue("picture") - fun isPrivate() = tags.hasTagWithContent("private") || !tags.hasTagWithContent("public") + /** + * Topic hashtags (`t` tags) the relay advertises for this group, used by the + * discovery feed's hashtag filter. NIP-29 doesn't define these; a group only + * carries them if its host relay copies the requested `t` tags onto the 39000. + */ + fun hashtags() = tags.hashtags() - fun isRestricted() = tags.hasTagWithContent("closed") || !tags.hasTagWithContent("open") + /** + * Geohashes (`g` tags) the relay advertises for this group, used by the discovery + * feed's geo filter. Same relay-cooperation caveat as [hashtags]. + */ + fun geohashes() = tags.geohashes() - fun isHidden() = tags.hasTagWithContent("private") + /** Only members can read. Presence of the `private` flag; absent = public read. */ + fun isPrivate() = tags.hasTagName("private") - fun isClosed() = tags.hasTagWithContent("closed") + /** Only members can write. Presence of the `restricted` flag; absent = open write. */ + fun isRestricted() = tags.hasTagName("restricted") + + /** Metadata hidden from non-members. Presence of the `hidden` flag. */ + fun isHidden() = tags.hasTagName("hidden") + + /** Join requests are ignored (invite-only). Presence of the `closed` flag; absent = open join. */ + fun isClosed() = tags.hasTagName("closed") + + /** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */ + fun hasLivekit() = tags.hasTagName("livekit") + + /** + * The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`. + * `null` (tag absent) means all kinds are accepted. + */ + fun supportedKinds(): List? = + tags + .firstOrNull { it.isNotEmpty() && it[0] == "supported_kinds" } + ?.drop(1) + ?.mapNotNull { it.toIntOrNull() } fun statusTags(): Set { val statuses = mutableSetOf() GroupStatus.entries.forEach { status -> - if (tags.hasTagWithContent(status.code)) { + if (tags.hasTagName(status.code)) { statuses.add(status) } } @@ -74,8 +108,11 @@ class GroupMetadataEvent( ) { PRIVATE("private"), PUBLIC("public"), + RESTRICTED("restricted"), OPEN("open"), CLOSED("closed"), + HIDDEN("hidden"), + LIVEKIT("livekit"), } companion object { @@ -87,6 +124,9 @@ class GroupMetadataEvent( about: String? = null, picture: String? = null, status: Set = emptySet(), + supportedKinds: List? = null, + hashtags: List = emptyList(), + geohashes: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -95,6 +135,12 @@ class GroupMetadataEvent( about?.let { add(arrayOf("about", it)) } picture?.let { add(arrayOf("picture", it)) } status.forEach { add(arrayOf(it.code)) } + supportedKinds?.let { kinds -> + add((listOf("supported_kinds") + kinds.map { it.toString() }).toTypedArray()) + } + addAll(HashtagTag.assemble(hashtags)) + // Mip-map each geohash into every prefix so a coarser followed geohash still matches. + geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt new file mode 100644 index 0000000000..dbb6d36337 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt @@ -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.quartz.nip29RelayGroups.metadata + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Relay-signed live audio/video room presence (kind 39004). A group that + * advertises the `livekit` metadata flag publishes this addressable event + * (`d` = group id) listing the pubkeys currently connected to the AV room, one + * per `["participant", ""]` tag. Updated by the relay as users join and + * leave. This is an extension used by LiveKit-backed deployments (e.g. Armada); + * it is not part of NIP-29 proper, but is parsed here for interop. + */ +@Immutable +class GroupParticipantsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = dTag() + + fun participants(): List = tags.mapValueTagged(TAG_NAME) { it } + + companion object { + const val KIND = 39004 + const val TAG_NAME = "participant" + + fun build( + groupId: String, + participantPubKeys: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(groupId) + participantPubKeys.forEach { add(arrayOf(TAG_NAME, it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index a5f263a96d..00695e733e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -26,6 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -46,6 +50,10 @@ class EditMetadataEvent( fun about() = tags.firstTagValue("about") + fun hashtags() = tags.hashtags() + + fun geohashes() = tags.geohashes() + fun previousEvents() = tags.previousEvents() override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n") @@ -59,6 +67,8 @@ class EditMetadataEvent( about: String? = null, picture: String? = null, status: Set = emptySet(), + hashtags: List = emptyList(), + geohashes: List = emptyList(), previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -68,6 +78,9 @@ class EditMetadataEvent( about?.let { add(arrayOf("about", it)) } picture?.let { add(arrayOf("picture", it)) } status.forEach { add(arrayOf(it.code)) } + addAll(HashtagTag.assemble(hashtags)) + // Mip-map each geohash into every prefix so a coarser followed geohash still matches. + geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } previous(previousEvents) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt index b3e860bdd0..969cfbb9ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/GroupTag.kt @@ -33,6 +33,17 @@ class GroupTag( fun toTagIdOnly() = arrayOf(TAG_NAME, groupId, relayUrl) + // Identity is the (group id, host relay) pair — the group's real key. The + // optional `name` is cosmetic and deliberately excluded, so the same group + // stored twice (e.g. once as a public tag, once decrypted from a private + // item, or with/without a cached name) collapses in a Set and a StateFlow of + // GroupTags stops re-emitting on every identical re-arrival of the list. + override fun equals(other: Any?): Boolean = + this === other || + (other is GroupTag && groupId == other.groupId && relayUrl == other.relayUrl) + + override fun hashCode(): Int = 31 * groupId.hashCode() + relayUrl.hashCode() + companion object { const val TAG_NAME = "group" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/TagArrayExt.kt new file mode 100644 index 0000000000..4a21ee7b39 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.simpleGroupList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** Parse every `["group", id, relay, name?]` item in this tag array (NIP-51). */ +fun TagArray.groups() = mapNotNull(GroupTag::parse) + +/** Parse every `group` item into a de-duplicated set. */ +fun TagArray.groupSet() = mapNotNullTo(mutableSetOf(), GroupTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt index 94ffa12233..1ddad0281f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip50Search.SearchableEvent @@ -42,7 +43,12 @@ class ThreadEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") - fun title() = tags.title() + /** + * The thread title. NIP-7D specifies a `title` tag (what we emit), but some + * NIP-29 clients (e.g. nostrord) put it in a `subject` tag instead, so fall + * back to that when reading so their threads still show a title. + */ + fun title() = tags.title() ?: tags.firstTagValue("subject") companion object { const val KIND = 11 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index e42c2b54ff..a1de1467cb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent 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 @@ -396,6 +397,7 @@ class EventFactory { GroupAdminsEvent.KIND -> GroupAdminsEvent(id, pubKey, createdAt, tags, content, sig) GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig) SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig) + GroupParticipantsEvent.KIND -> GroupParticipantsEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 5c250e2d4d..6462aa8cca 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip19Bech32 +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -460,4 +461,26 @@ class NIP19ParserTest { withBom.bechToBytes().toNsec(), ) } + + // A 33-byte COMPRESSED secp256k1 key (0x02 prefix). Nostr pubkeys are x-only 32 bytes; some + // clients wrongly encode the compressed form. Parsing must reject it so its 66-char hex never + // reaches a `p`/`q` tag (strict relays like relay29 reject the whole event otherwise). + private val compressedPubKey = "02977dcfdd3ce61572b652e0eade0fd519108be00a74b00064eb35b746cf6c3402" + + @Test + fun rejects_compressed_pubkey_encoded_as_npub() { + assertEquals(null, NPub.parse(compressedPubKey.hexToByteArray())) + assertEquals(null, Nip19Parser.uriToRoute("nostr:" + NPub.create(compressedPubKey))?.entity) + } + + @Test + fun rejects_compressed_pubkey_encoded_as_nprofile() { + assertEquals(null, Nip19Parser.uriToRoute("nostr:" + NProfile.create(compressedPubKey, emptyList()))?.entity) + } + + @Test + fun accepts_valid_xonly_pubkey() { + val xOnly = "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365" + assertEquals(xOnly, NPub.parse(xOnly.hexToByteArray())?.hex) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt new file mode 100644 index 0000000000..032d99896a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Covers the de-facto `'[?code=]` invite-link format shared by + * Wisp and 0xchat. [GroupInviteLink.parse] is the deep-link / inline-tap entry point; + * [GroupInviteLink.suffixLength] is the primitive the note linkifier uses to keep the + * `'…` span glued to the relay URL the detector already found. + */ +class GroupInviteLinkTest { + @Test + fun parsesBareInvite() { + val link = GroupInviteLink.parse("wss://groups.0xchat.com'abc123")!! + assertEquals("abc123", link.groupId) + assertNull(link.code) + assertTrue(link.relayUrl.url.startsWith("wss://groups.0xchat.com")) + } + + @Test + fun parsesInviteWithCode() { + val link = GroupInviteLink.parse("wss://chat.wisp.talk'my-group?code=xyz789")!! + assertEquals("my-group", link.groupId) + assertEquals("xyz789", link.code) + } + + @Test + fun parsesDefaultUnderscoreGroup() { + val link = GroupInviteLink.parse("wss://relay.example.com'_")!! + assertEquals("_", link.groupId) + } + + @Test + fun ignoresExtraQueryParams() { + val link = GroupInviteLink.parse("wss://relay.example.com'g1?foo=bar&code=abc")!! + assertEquals("g1", link.groupId) + assertEquals("abc", link.code) + } + + @Test + fun rejectsNoApostrophe() { + assertNull(GroupInviteLink.parse("wss://relay.example.com")) + } + + @Test + fun rejectsEmptyGroupId() { + assertNull(GroupInviteLink.parse("wss://relay.example.com'")) + assertNull(GroupInviteLink.parse("wss://relay.example.com'?code=abc")) + } + + @Test + fun rejectsNonRelayHost() { + // A leading apostrophe (no host) or a plainly invalid host must not parse. + assertNull(GroupInviteLink.parse("'abc123")) + } + + @Test + fun rejectsSingleCharPossessive() { + // `wss://relay.damus.io's uptime` — the `'s` is a possessive, not a group id. + assertNull(GroupInviteLink.parse("wss://relay.damus.io's")) + } + + @Test + fun acceptsUnderscoreDefaultGroupButNotOtherSingleChars() { + // `_` is the relay-wide default group and is a legitimate 1-char id; other lone + // chars are rejected as contractions. + assertEquals("_", GroupInviteLink.parse("wss://relay.example.com'_")?.groupId) + assertNull(GroupInviteLink.parse("wss://relay.example.com'x")) + } + + @Test + fun suffixLengthRejectsPossessiveS() { + // "wss://r.com's uptime" — apostrophe at 11, `s` at 12 → not a link. + assertEquals(0, GroupInviteLink.suffixLength("wss://r.com's uptime", 12)) + } + + @Test + fun suffixLengthAcceptsUnderscore() { + assertEquals(1, GroupInviteLink.suffixLength("wss://r.com'_ hi", 12)) + } + + @Test + fun suffixLengthMeasuresGroupIdOnly() { + // "wss://r.com'abc def" — apostrophe at index 11, group id starts at 12. + val content = "wss://r.com'abc def" + assertEquals("abc".length, GroupInviteLink.suffixLength(content, 12)) + } + + @Test + fun suffixLengthIncludesCode() { + val content = "wss://r.com'abc?code=xyz rest" + assertEquals("abc?code=xyz".length, GroupInviteLink.suffixLength(content, 12)) + } + + @Test + fun suffixLengthStopsAtProsePunctuation() { + // A possessive apostrophe in prose: "it's" — after the ' there is no id run + // that we would want, but here we assert the scanner is bounded to id chars + // and stops at the space. + val content = "wss://r.com'group, and more" + assertEquals("group".length, GroupInviteLink.suffixLength(content, 12)) + } + + @Test + fun suffixLengthZeroWhenNoGroupId() { + val content = "wss://r.com' next" + assertEquals(0, GroupInviteLink.suffixLength(content, 12)) + } + + @Test + fun suffixLengthDropsDanglingCodePrefix() { + // "?code=" with no actual code after it must not extend the span. + val content = "wss://r.com'abc?code=" + assertEquals("abc".length, GroupInviteLink.suffixLength(content, 12)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt new file mode 100644 index 0000000000..33703c3ae3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -0,0 +1,442 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +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.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Interop tests asserting that Quartz can PARSE every NIP-29 event Armada + * (gitlab.com/soapbox-pub/armada) produces — with the exact tag shapes Armada + * emits — and BUILD events whose tags match what Armada expects. Tag shapes are + * taken verbatim from Armada's `client/src/lib/nip29.ts` and its hooks. + */ +class Nip29ArmadaInteropTest { + private val relaySelf = "aa".repeat(32) + private val alice = "bb".repeat(32) + private val bob = "cc".repeat(32) + private val gid = "0123456789abcdef" + + private fun parse( + kind: Int, + tags: Array>, + content: String = "", + pubKey: String = relaySelf, + ): Event = + EventFactory.create( + id = "00".repeat(32), + pubKey = pubKey, + createdAt = 1_700_000_000L, + kind = kind, + tags = tags, + content = content, + sig = "22".repeat(64), + ) + + // ── Relay-signed metadata (39000-39004) ────────────────────────────────── + + @Test + fun parsesGroupMetadataWithEveryFlag() { + // Armada relay29 emits presence flags as single-element tags. + val e = + parse( + GroupMetadataEvent.KIND, + arrayOf( + arrayOf("d", gid), + arrayOf("name", "General"), + arrayOf("about", "the main room"), + arrayOf("picture", "https://x/y.png"), + arrayOf("private"), + arrayOf("restricted"), + arrayOf("hidden"), + arrayOf("closed"), + arrayOf("livekit"), + arrayOf("supported_kinds", "9", "11"), + ), + ) + assertTrue(e is GroupMetadataEvent) + assertEquals(gid, e.groupId()) + assertEquals("General", e.name()) + assertEquals("the main room", e.about()) + assertEquals("https://x/y.png", e.picture()) + assertTrue(e.isPrivate()) + assertTrue(e.isRestricted()) + assertTrue(e.isHidden()) + assertTrue(e.isClosed()) + assertTrue(e.hasLivekit()) + assertEquals(listOf(9, 11), e.supportedKinds()) + } + + @Test + fun parsesOpenPublicGroupAsUnrestricted() { + // Regression: a group with NO flag tags is public/open/visible — the old + // accessors defaulted several of these to `true`. + val e = parse(GroupMetadataEvent.KIND, arrayOf(arrayOf("d", gid), arrayOf("name", "Open"))) as GroupMetadataEvent + assertFalse(e.isPrivate()) + assertFalse(e.isRestricted()) + assertFalse(e.isHidden()) + assertFalse(e.isClosed()) + assertFalse(e.hasLivekit()) + assertNull(e.supportedKinds()) + assertEquals("Open", e.name()) + } + + @Test + fun fallsBackToGroupIdWhenNoName() { + val e = parse(GroupMetadataEvent.KIND, arrayOf(arrayOf("d", gid))) as GroupMetadataEvent + assertNull(e.name()) + assertEquals(gid, e.groupId()) + } + + @Test + fun parsesGroupAdminsWithRoles() { + // Armada: ["p", pubkey, "role1", "role2"] + val e = + parse( + GroupAdminsEvent.KIND, + arrayOf( + arrayOf("d", gid), + arrayOf("p", alice, "admin"), + arrayOf("p", bob, "moderator", "ceo"), + ), + ) as GroupAdminsEvent + val admins = e.admins() + assertEquals(2, admins.size) + assertEquals(alice, admins[0].pubKey) + assertEquals(listOf("admin"), admins[0].roles) + assertEquals(listOf("moderator", "ceo"), admins[1].roles) + } + + @Test + fun parsesGroupMembers() { + val e = + parse( + GroupMembersEvent.KIND, + arrayOf(arrayOf("d", gid), arrayOf("p", alice), arrayOf("p", bob)), + ) as GroupMembersEvent + assertEquals(listOf(alice, bob), e.members()) + } + + @Test + fun parsesGroupRoles() { + val e = + parse( + SupportedRolesEvent.KIND, + arrayOf( + arrayOf("role", "admin", "Full control"), + arrayOf("role", "moderator"), + ), + ) as SupportedRolesEvent + val roles = e.roles() + assertEquals("admin", roles[0].name) + assertEquals("Full control", roles[0].description) + assertEquals("moderator", roles[1].name) + assertNull(roles[1].description) + } + + @Test + fun parsesGroupParticipants() { + // Armada AV: ["participant", pubkey] + val e = + parse( + GroupParticipantsEvent.KIND, + arrayOf(arrayOf("d", gid), arrayOf("participant", alice), arrayOf("participant", bob)), + ) + assertTrue(e is GroupParticipantsEvent) + assertEquals(gid, e.groupId()) + assertEquals(listOf(alice, bob), e.participants()) + } + + // ── User moderation/request events (9xxx) ──────────────────────────────── + + @Test + fun parsesModerationEvents() { + assertTrue(parse(CreateGroupEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) is CreateGroupEvent) + assertTrue(parse(DeleteGroupEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) is DeleteGroupEvent) + + val put = parse(PutUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob, "moderator")), pubKey = alice) as PutUserEvent + assertEquals(gid, put.groupId()) + assertEquals(listOf(bob), put.userPubKeys()) + + val remove = parse(RemoveUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob)), content = "spam", pubKey = alice) as RemoveUserEvent + assertEquals(listOf(bob), remove.userPubKeys()) + assertEquals("spam", remove.content) + + val del = parse(DeleteEventEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("e", "de".repeat(32))), pubKey = alice) as DeleteEventEvent + assertEquals(listOf("de".repeat(32)), del.deletedEventIds()) + + val invite = parse(CreateInviteEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "abc123")), pubKey = alice) as CreateInviteEvent + assertEquals("abc123", invite.code()) + + val edit = parse(EditMetadataEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("name", "N"), arrayOf("public"), arrayOf("open")), pubKey = alice) as EditMetadataEvent + assertEquals("N", edit.name()) + assertEquals(gid, edit.groupId()) + } + + @Test + fun parsesJoinAndLeaveRequests() { + val join = parse(JoinRequestEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "inv1")), content = "let me in", pubKey = alice) as JoinRequestEvent + assertEquals(gid, join.groupId()) + assertEquals("inv1", join.inviteCode()) + assertEquals("let me in", join.content) + + // Armada also sends a join with no code. + val joinNoCode = parse(JoinRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as JoinRequestEvent + assertNull(joinNoCode.inviteCode()) + + val leave = parse(LeaveRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as LeaveRequestEvent + assertEquals(gid, leave.groupId()) + } + + // ── Group-scoped content (kind 9/11/7) via the `h` tag ─────────────────── + + @Test + fun parsesGroupChatMessageWithHTag() { + // A NIP-29 chat message is a kind-9 ChatEvent carrying an `h` tag. + val e = parse(ChatEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("t", "gm")), content = "gm", pubKey = alice) + assertTrue(e is ChatEvent) + assertEquals(gid, e.groupId()) + assertTrue(e.isGroupScoped()) + } + + @Test + fun parsesGroupThreadAndReactionWithHTag() { + val thread = parse(ThreadEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("title", "T")), content = "body", pubKey = alice) + assertTrue(thread is ThreadEvent) + assertEquals(gid, thread.groupId()) + + val react = parse(ReactionEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("e", "ee".repeat(32))), content = "🔥", pubKey = alice) + assertEquals(gid, react.groupId()) + } + + @Test + fun ungroupedEventHasNoGroupId() { + val e = parse(ChatEvent.KIND, arrayOf(arrayOf("t", "hi")), content = "hi", pubKey = alice) + assertNull(e.groupId()) + assertFalse(e.isGroupScoped()) + } + + // ── kind 10009 user group list (NIP-51 simple groups) ──────────────────── + + @Test + fun groupTagIdentityIsIdAndRelayNotName() { + // Same (id, relay) is the same group regardless of the cached name, so a + // Set dedups it — otherwise the same group stored as a public tag and a + // private item would show twice and the joined-list flow would churn. + val a = GroupTag(gid, "wss://r", "Alpha") + val b = GroupTag(gid, "wss://r", null) + val c = GroupTag(gid, "wss://other", "Alpha") + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + assertEquals(1, setOf(a, b).size) + assertTrue(a != c) + } + + @Test + fun parsesUserGroupListPublicGroups() { + // Armada: ["group", id, relay] + val relay = "wss://relay.example.com" + val e = + parse( + SimpleGroupListEvent.KIND, + arrayOf(arrayOf("group", gid, relay), arrayOf("r", relay)), + pubKey = alice, + ) as SimpleGroupListEvent + val groups = e.publicGroups() + assertEquals(1, groups.size) + assertEquals(gid, groups[0].groupId) + assertEquals(relay, groups[0].relayUrl) + } + + // ── Build side: tags match what Armada / relay29 expect ────────────────── + + @Test + fun buildsGroupMetadataWithFlagsAndSupportedKinds() { + val tags = + GroupMetadataEvent + .build( + groupId = gid, + name = "General", + about = "hi", + status = setOf(GroupMetadataEvent.GroupStatus.PRIVATE, GroupMetadataEvent.GroupStatus.CLOSED), + supportedKinds = listOf(9, 11), + ).tags + assertTrue(tags.any { it[0] == "d" && it[1] == gid }) + assertTrue(tags.any { it[0] == "name" && it[1] == "General" }) + assertTrue(tags.any { it.size == 1 && it[0] == "private" }) + assertTrue(tags.any { it.size == 1 && it[0] == "closed" }) + assertTrue(tags.any { it[0] == "supported_kinds" && it.drop(1) == listOf("9", "11") }) + + // And it round-trips back through the parser. + val parsed = parse(GroupMetadataEvent.KIND, tags) as GroupMetadataEvent + assertTrue(parsed.isPrivate()) + assertTrue(parsed.isClosed()) + assertFalse(parsed.isRestricted()) + assertEquals(listOf(9, 11), parsed.supportedKinds()) + } + + @Test + fun parsesGroupMetadataTopicsAndGeohashes() { + // Amethyst-extension: a relay that copies requested topics/geo onto the 39000. + val e = + parse( + GroupMetadataEvent.KIND, + arrayOf( + arrayOf("d", gid), + arrayOf("name", "Bitcoin Devs"), + arrayOf("t", "bitcoin"), + arrayOf("t", "nostr"), + arrayOf("g", "u0nd"), + ), + ) as GroupMetadataEvent + assertEquals(listOf("bitcoin", "nostr"), e.hashtags()) + assertEquals(listOf("u0nd"), e.geohashes()) + } + + @Test + fun buildsGroupMetadataWithTopicsAndGeohashes() { + val tags = + GroupMetadataEvent + .build( + groupId = gid, + name = "Bitcoin Devs", + hashtags = listOf("Bitcoin"), + geohashes = listOf("u0nd"), + ).tags + // HashtagTag.assemble lowercases mixed-case topics alongside the original. + assertTrue(tags.any { it[0] == "t" && it[1] == "bitcoin" }) + // GeoHashTag.assemble mip-maps the geohash into every prefix. + assertTrue(tags.any { it[0] == "g" && it[1] == "u0nd" }) + assertTrue(tags.any { it[0] == "g" && it[1] == "u" }) + + val parsed = parse(GroupMetadataEvent.KIND, tags) as GroupMetadataEvent + assertTrue(parsed.hashtags().contains("bitcoin")) + assertTrue(parsed.geohashes().contains("u0nd")) + } + + @Test + fun buildsEditMetadataWithTopicsAndGeohashes() { + val edit = + EditMetadataEvent.build( + groupId = gid, + name = "Bitcoin Devs", + hashtags = listOf("bitcoin"), + geohashes = listOf("u0nd"), + ) + assertTrue(edit.tags.any { it[0] == "t" && it[1] == "bitcoin" }) + assertTrue(edit.tags.any { it[0] == "g" && it[1] == "u0nd" }) + } + + @Test + fun buildsCreateAndModerationEvents() { + assertTrue(CreateGroupEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid }) + assertTrue(CreateInviteEvent.build(gid, "code42").tags.any { it[0] == "code" && it[1] == "code42" }) + + val put = PutUserEvent.build(gid, listOf(bob to listOf("moderator"))).tags + assertTrue(put.any { it[0] == "p" && it[1] == bob && it.contains("moderator") }) + + val remove = RemoveUserEvent.build(gid, listOf(bob)).tags + assertTrue(remove.any { it[0] == "p" && it[1] == bob }) + + // relay29 forks are avoided by NOT emitting `previous` when none provided. + assertFalse(put.any { it[0] == "previous" }) + } + + @Test + fun readsThreadTitleFromTitleOrSubject() { + // NIP-7D uses a `title` tag; nostrord uses `subject`. We read either. + val withTitle = parse(ThreadEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("title", "Welcome")), pubKey = alice) as ThreadEvent + assertEquals("Welcome", withTitle.title()) + + val withSubject = parse(ThreadEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("subject", "Howdy")), pubKey = alice) as ThreadEvent + assertEquals("Howdy", withSubject.title()) + + // `title` wins when both are present. + val both = parse(ThreadEvent.KIND, arrayOf(arrayOf("title", "T"), arrayOf("subject", "S")), pubKey = alice) as ThreadEvent + assertEquals("T", both.title()) + + // We emit the spec-correct `title` tag, never `subject`. + val built = ThreadEvent.build("body", "Hello") { hTag(gid) } + assertTrue(built.tags.any { it[0] == "title" && it[1] == "Hello" }) + assertTrue(built.tags.none { it[0] == "subject" }) + } + + @Test + fun buildsGroupThreadReplyWithHTagAndRootReference() { + // A reply to a kind-11 group thread must be a 1111 comment that BOTH roots + // at the thread AND carries the group `h` tag — the exact composition the + // Android composer builds (CommentEvent.replyBuilder { hTag(...) }). + val thread = + parse( + ThreadEvent.KIND, + arrayOf(arrayOf("h", gid), arrayOf("title", "T")), + content = "body", + pubKey = alice, + ) as ThreadEvent + + val reply = CommentEvent.replyBuilder("nice", EventHintBundle(thread)) { hTag(gid) } + + assertEquals(CommentEvent.KIND, reply.kind) + assertEquals(gid, reply.tags.hTag()) + // References the thread it replies to (NIP-22 root scope carries the id). + assertTrue(reply.tags.any { it.size > 1 && it[1] == thread.id }) + } + + @Test + fun buildsJoinLeaveAndGroupScopedChat() { + val join = JoinRequestEvent.build(gid, reason = "hi", inviteCode = "inv9") + assertEquals("hi", join.content) + assertTrue(join.tags.any { it[0] == "h" && it[1] == gid }) + assertTrue(join.tags.any { it[0] == "code" && it[1] == "inv9" }) + + assertTrue(LeaveRequestEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid }) + + // Group-scope a plain kind-9 chat message via the shared `hTag` helper. + val chat = ChatEvent.build("gm") { hTag(gid) } + assertEquals(gid, chat.tags.hTag()) + } +}