From da2f2ad5ab59c5518858989893e466c367976033 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 19:23:40 +0000 Subject: [PATCH 01/81] docs: deep study of Armada (NIP-29) + Amethyst integration plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Study of Soapbox's Armada NIP-29 client — event creation, chat management, display, invites/join/leave, relay-scoping discipline — and a concrete plan for bringing NIP-29 relay-based groups into Amethyst, mapping onto the existing nip29RelayGroups/nip43RelayMembers Quartz packages and the ephemChat (relay-scoped chat) UX analog. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../plans/2026-07-07-nip29-armada-study.md | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 amethyst/plans/2026-07-07-nip29-armada-study.md 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 From 2620ce9a506fe143a0ad116efebb7d09471c9cfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 19:45:26 +0000 Subject: [PATCH 02/81] feat(quartz): make NIP-29 event layer a verified superset of Armada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited Quartz's nip29RelayGroups package against every event and tag Armada (gitlab.com/soapbox-pub/armada) creates and reads, and closed the gaps so Quartz can round-trip all of them. - GroupMetadataEvent (39000): fix flag parsing. `private`/`restricted`/ `hidden`/`closed`/`livekit` are single-element presence tags, but the old accessors used hasTagWithContent (requires a value) AND conflated restricted<->closed and hidden<->private with wrong defaults (an open public group parsed as private+restricted). Now each flag is a plain presence check. Add hasLivekit() and supportedKinds(), extend GroupStatus with RESTRICTED/HIDDEN/LIVEKIT, and support supported_kinds in build(). - Add GroupParticipantsEvent (kind 39004, LiveKit AV presence) and register it in EventFactory + KindNames. - Add TagArray.hasTagName() presence helper in nip01Core. - Add GroupScope.kt: hTag() builder + Event.groupId()/isGroupScoped() readers so kind 9 (chat), 11 (thread), 7 (reaction), 1068, 1111 etc. can be group-scoped via the `h` tag and read back — NIP-29 reuses these carriers rather than defining new content kinds. - Add Nip29ArmadaInteropTest covering parse + build for every event with Armada's exact tag shapes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../quartz/nip01Core/core/TagArray.kt | 7 + .../quartz/nip29RelayGroups/GroupScope.kt | 57 +++ .../metadata/GroupMetadataEvent.kt | 36 +- .../metadata/GroupParticipantsEvent.kt | 68 ++++ .../quartz/utils/EventFactory.kt | 2 + .../Nip29ArmadaInteropTest.kt | 333 ++++++++++++++++++ 7 files changed, 499 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt 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 774dd47b32..8634b5d422 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 @@ -611,6 +612,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/nip29RelayGroups/GroupScope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt new file mode 100644 index 0000000000..b2141b96b4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.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.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.nip29RelayGroups.tags.GroupIdTag + +/* + * 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 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..09b7b07d42 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,7 +25,7 @@ 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.nip50Search.SearchableEvent @@ -51,18 +51,35 @@ class GroupMetadataEvent( fun picture() = tags.firstTagValue("picture") - fun isPrivate() = tags.hasTagWithContent("private") || !tags.hasTagWithContent("public") + /** Only members can read. Presence of the `private` flag; absent = public read. */ + fun isPrivate() = tags.hasTagName("private") - fun isRestricted() = tags.hasTagWithContent("closed") || !tags.hasTagWithContent("open") + /** Only members can write. Presence of the `restricted` flag; absent = open write. */ + fun isRestricted() = tags.hasTagName("restricted") - fun isHidden() = tags.hasTagWithContent("private") + /** Metadata hidden from non-members. Presence of the `hidden` flag. */ + fun isHidden() = tags.hasTagName("hidden") - fun isClosed() = tags.hasTagWithContent("closed") + /** 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 +91,11 @@ class GroupMetadataEvent( ) { PRIVATE("private"), PUBLIC("public"), + RESTRICTED("restricted"), OPEN("open"), CLOSED("closed"), + HIDDEN("hidden"), + LIVEKIT("livekit"), } companion object { @@ -87,6 +107,7 @@ class GroupMetadataEvent( about: String? = null, picture: String? = null, status: Set = emptySet(), + supportedKinds: List? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -95,6 +116,9 @@ 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()) + } 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/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/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt new file mode 100644 index 0000000000..071ddaeb32 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -0,0 +1,333 @@ +/* + * 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.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.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 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 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 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()) + } +} From dc4fe7500f7f992177e4e0373b1ea11b38a10f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:05:35 +0000 Subject: [PATCH 03/81] feat(nip29): add RelayGroupChannel model + group message datasource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First feature-layer slice for NIP-29 relay groups, cloning the ephemeral-chat (NIP-C7) pattern and reusing the existing relay-pinned send/subscribe primitives (RelayBasedFilter + signAndSendPrivatelyOrBroadcast) — no new transport. - quartz: GroupId(id, relayUrl) identifier for a relay group (host relay + group id), mirroring ephemChat's RoomId. - commons: RelayGroupChannel — a metadata-backed Channel (like NIP-28's PublicChatChannel) keyed by GroupId, deriving name/picture/about/flags from the relay-signed kind 39000 event and pinning relays() to the single host. - amethyst: filterMessagesToRelayGroup / filterMyMessagesToRelayGroup datasource sub-assemblers — kind 9 + poll timeline scoped by the `h` tag and pinned to the host relay via RelayBasedFilter, mirroring the ephemeral-chat sub-assemblers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../FilterMessagesToRelayGroup.kt | 55 ++++++++++++ .../FilterMyMessagesToRelayGroup.kt | 57 ++++++++++++ .../nip29RelayGroups/RelayGroupChannel.kt | 87 +++++++++++++++++++ .../quartz/nip29RelayGroups/GroupId.kt | 48 ++++++++++ 4 files changed, 247 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToRelayGroup.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToRelayGroup.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupId.kt 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/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/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..3c61880574 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.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.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.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/** + * 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 + + /** 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. + if (event.createdAt < updatedMetadataAt) return + this.event = event + this.metadataNote = eventNote + this.updatedMetadataAt = event.createdAt + updateChannelInfo() + } + + fun anyNameStartsWith(prefix: String): Boolean = + groupId.id.contains(prefix, true) || + event?.name()?.contains(prefix, true) == true || + event?.about()?.contains(prefix, true) == true +} 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 + } + } +} From 5ce9eeb77d2fd1e771deb3e375409208aaa30b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:26:51 +0000 Subject: [PATCH 04/81] feat(nip29): wire relay groups into LocalCache Populate RelayGroupChannel from incoming events, mirroring the ephemeral-chat consume path: - relayGroupChannels cache keyed by GroupId + getOrCreate/getIfExists. - consume(GroupMetadataEvent): store the kind 39000 addressable note and update the channel's name/picture/about/flags. Keyed by (serving relay + group id): NIP-29 events don't carry their host relay, so the channel is associated only when the serving relay (provenance) is known. - attachToRelayGroupIfScoped(): route kind-9 chat + kind-1068 poll events that carry an `h` tag into their group's channel timeline, on top of normal consume. - Dispatch GroupMetadataEvent and the group-aware ChatEvent/PollEvent branches. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/model/LocalCache.kt | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) 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 b5c27ae39f..ca79b64dca 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,9 @@ 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.GroupMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -334,6 +338,7 @@ object LocalCache : ILocalCache, ICacheProvider { val publicChatChannels = LargeCache() val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() + val relayGroupChannels = LargeCache() val paymentTracker = NwcPaymentTracker() @@ -607,6 +612,8 @@ object LocalCache : ILocalCache, ICacheProvider { fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key) + fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key) + fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key) fun getNoteIfExists(event: Event): Note? = @@ -695,6 +702,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 +1731,47 @@ 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 + } + + /** + * 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 + if (relay == null) return + val channel = getOrCreateRelayGroupChannel(GroupId(groupId, relay)) + channel.addNote(getOrCreateNote(event.id), relay) + } + fun consume( event: LiveActivitiesChatMessageEvent, relay: NormalizedRelayUrl?, @@ -3543,6 +3593,10 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is GroupMetadataEvent -> { + consume(event, relay, wasVerified) + } + is ExternalIdentitiesEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } @@ -3897,11 +3951,15 @@ object LocalCache : ILocalCache, ICacheProvider { } is ChatEvent -> { - consumeRegularEvent(event, relay, wasVerified) + consumeRegularEvent(event, relay, wasVerified).also { + if (it) attachToRelayGroupIfScoped(event, relay) + } } is PollEvent -> { - consumeRegularEvent(event, relay, wasVerified) + consumeRegularEvent(event, relay, wasVerified).also { + if (it) attachToRelayGroupIfScoped(event, relay) + } } is PollResponseEvent -> { From 32e8a8a7edaf46cd95ff27e3ae0dd0d7333e6c1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:29:30 +0000 Subject: [PATCH 05/81] feat(quartz): add group()/groupSet() tag helpers for kind 10009 TagArray.groups()/groupSet() parse the NIP-51 simple-groups list `["group", id, relay, name?]` items, mirroring ephemChat's rooms()/roomSet(). Needed for the upcoming relay-group list state (the user's joined groups + servers). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../nip51Lists/simpleGroupList/TagArrayExt.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/TagArrayExt.kt 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) From 72755fd5208c30b03c51e7d08ef5654e080038cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:37:45 +0000 Subject: [PATCH 06/81] feat(nip29): subscribe relay-group channels (A) Wire RelayGroupChannel into the shared channel subscription orchestration so an open group streams live: - filterMetadataToRelayGroup: relay-signed directory (39000-39003) by #d, pinned to the host relay. - ChannelPublicFilterSubAssembler: RelayGroupChannel branch = messages + metadata. - ChannelFromUserFilterSubAssembler: RelayGroupChannel branch = my messages (optimistic-send reconciliation). The EOSE/subscription lifecycle (PerUniqueIdEoseManager, ChannelQueryState) is already generic over Channel, so registering a ChannelQueryState for a group is all a screen needs to go live. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../ChannelFromUserFilterSubAssembler.kt | 2 + .../ChannelPublicFilterSubAssembler.kt | 6 ++ .../FilterMetadataToRelayGroup.kt | 61 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt 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/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, + ), + ) + } From 8d5f48fbc04a9a0ac88e4a5849a95827146169c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:45:35 +0000 Subject: [PATCH 07/81] feat(nip29): account-level relay-group list state (B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's joined NIP-29 groups (kind 10009, NIP-51 simple-groups), mirroring the ephemeral-chat list infrastructure: - commons: RelayGroupListState over SimpleGroupListEvent — liveRelayGroupList (Set), liveRelayGroupServers (distinct host relays for the Messages- tab rail), follow()/unfollow() a RelayGroupChannel; joined groups stored as NIP-44 private items. RelayGroupListDecryptionCache decrypts once. - AccountSettings implements RelayGroupRepository (backupRelayGroupList + get/ update); guards on null only, since private items live in encrypted content. - Account instantiates the state and exposes follow/unfollow(RelayGroupChannel). - LocalPreferences persists/restores the kind 10009 backup across restarts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/LocalPreferences.kt | 7 + .../vitorpamplona/amethyst/model/Account.kt | 10 ++ .../amethyst/model/AccountSettings.kt | 18 +++ .../RelayGroupListDecryptionCache.kt | 47 ++++++ .../nip29RelayGroups/RelayGroupListState.kt | 149 ++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionCache.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c853fc7cc1..02bba66e51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -59,6 +59,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 @@ -150,6 +151,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" @@ -498,6 +500,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) @@ -663,6 +666,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 +726,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 +778,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() @@ -852,6 +858,7 @@ object LocalPreferences { backupHashtagList = latestHashtagListResolved, backupGeohashList = latestGeohashListResolved, backupEphemeralChatList = latestEphemeralListResolved, + backupRelayGroupList = latestRelayGroupListResolved, backupTrustProviderList = latestTrustProviderListResolved, lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), 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..ccd4765b60 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 @@ -365,6 +368,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) @@ -1445,6 +1451,10 @@ 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)) + 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..04a004eba1 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,7 @@ 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.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver @@ -58,6 +59,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 @@ -242,6 +244,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, @@ -270,6 +273,7 @@ class AccountSettings( val callsEnabled: MutableStateFlow = MutableStateFlow(true), val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST), ) : EphemeralChatRepository, + RelayGroupRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal()) @@ -1213,6 +1217,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/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..3286b19b4d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListState.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.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 stored as + * `["group", id, relay, name?]` items, either public or NIP-44-encrypted private + * (encrypted to self). 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() + + return if (relayGroupList == null) { + SimpleGroupListEvent.create( + privateGroups = listOf(group), + signer = signer, + ) + } else { + SimpleGroupListEvent.add( + earlierVersion = relayGroupList, + group = group, + isPrivate = true, + 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) + } + } + } + } +} From 154264f149c5c8943921cf056cd1270311c01e0f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:53:32 +0000 Subject: [PATCH 08/81] feat(nip29): relay-group chat screen + navigation (C, part 1) An end-to-end, reachable NIP-29 group chat screen, reusing the shared channel feed/composer/subscription stack: - RelayGroupChatScreen + RelayGroupChannelView + LoadRelayGroupChannel + RelayGroupTopBar (mirrors the ephemeral-chat screen), driving the generic ChannelFeedViewModel / ChannelNewMessageViewModel / ChannelFilterAssembler- Subscription against a RelayGroupChannel. - ChannelNewMessageViewModel: RelayGroupChannel branch builds a kind-9 ChatEvent scoped with the `h` tag, published only to the group's host relay. - AccountViewModel: get/checkGetOrCreate RelayGroupChannel by GroupId. - Navigation: Route.RelayGroup, routeFor(RelayGroupChannel)/routeFor(GroupId), and the AppNavigation registration. Opening a group (relay + id) now streams its live timeline and sends messages. Remaining C: the Messages-tab entries listing chat relays and their channels. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/navigation/AppNavigation.kt | 12 ++ .../ui/navigation/routes/RouteMaker.kt | 6 + .../amethyst/ui/navigation/routes/Routes.kt | 7 + .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../relayGroup/LoadRelayGroupChannel.kt | 41 +++++ .../relayGroup/RelayGroupChannelView.kt | 151 ++++++++++++++++++ .../relayGroup/RelayGroupChatScreen.kt | 63 ++++++++ .../relayGroup/RelayGroupTopBar.kt | 66 ++++++++ .../send/ChannelNewMessageViewModel.kt | 21 +++ 9 files changed, 373 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/LoadRelayGroupChannel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt 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..c12767f096 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,7 @@ 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.RelayGroupChatScreen 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 @@ -555,6 +556,17 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupChatScreen( + id = it.id, + relayUrl = it.relayUrl, + draftId = it.draftId, + replyToId = it.replyTo, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } 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..3591a48368 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,7 @@ 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.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -257,6 +259,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..5c4dead38b 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 @@ -637,6 +637,13 @@ 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, + ) : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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 d2b563a1bb..bc3c9e700f 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 @@ -142,6 +143,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 @@ -1704,6 +1706,10 @@ 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 getLiveActivityChannelIfExists(key: Address) = LocalCache.getLiveActivityChannelIfExists(key) fun loadParticipants( 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/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt new file mode 100644 index 0000000000..34b2833699 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -0,0 +1,151 @@ +/* + * 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.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +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.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) + + EditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = feedViewModel.feedState::sendToTop, + nav, + ) + } +} 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..5bf356ce4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt @@ -0,0 +1,63 @@ +/* + * 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, + 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, 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/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt new file mode 100644 index 0000000000..0c6302f02b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -0,0 +1,66 @@ +/* + * 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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +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.screen.loggedIn.AccountViewModel + +@Composable +fun RelayGroupTopBar( + baseChannel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Recompose the title when the relay-signed metadata (name) arrives/changes. + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + + TopBarExtensibleWithBackButton( + title = { + Column { + Text( + text = channel.toBestDisplayName(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = channel.groupId.relayUrl.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + popBack = nav::popBack, + ) +} 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..87d0976198 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 @@ -84,6 +85,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 +100,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 +546,24 @@ 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. + 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 } From b03b335466e0c7587da3d12e933216b786cf9152 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:43:12 +0000 Subject: [PATCH 09/81] feat(nip29): relay channel-directory screen + subscription (C, part 2) The shared backbone both Messages-tab view modes need: browse every channel a relay hosts. - RelayGroupDirectoryFilterAssembler + subscription: streams a relay's kind 39000-39003 directory (registered in RelaySubscriptionsCoordinator), consumed into per-group RelayGroupChannels. - LocalCache.getRelayGroupChannelsOnRelay + AccountViewModel accessor enumerate a relay's channels. - RelayGroupChannelListScreen lists them (name + topic), tap opens the chat; Route.RelayGroupServer + AppNavigation registration. Next: the view-mode setting (default inline) and the Messages-tab entries (grouped relay rows vs inline channel rows with a relay chip). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/model/LocalCache.kt | 3 + .../RelaySubscriptionsCoordinator.kt | 3 + .../amethyst/ui/navigation/AppNavigation.kt | 9 ++ .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../ui/screen/loggedIn/AccountViewModel.kt | 2 + .../relayGroup/RelayGroupChannelListScreen.kt | 131 ++++++++++++++++++ .../RelayGroupDirectoryFilterAssembler.kt | 93 +++++++++++++ .../RelayGroupDirectorySubscription.kt | 41 ++++++ 8 files changed, 286 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt 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 ca79b64dca..286796875a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -614,6 +614,9 @@ object LocalCache : ILocalCache, ICacheProvider { 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? = 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..fc25678611 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,7 @@ 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.RelayGroupDirectoryFilterAssembler 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 +114,7 @@ class RelaySubscriptionsCoordinator( // active depending on the screen. val channel = ChannelFilterAssembler(client) + val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -174,6 +176,7 @@ class RelaySubscriptionsCoordinator( val all = listOf( + relayGroupDirectory, account, accountForeground, home, 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 c12767f096..ee39529e56 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,7 @@ 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.RelayGroupChannelListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen @@ -567,6 +568,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupChannelListScreen( + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } 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 5c4dead38b..957963f2fc 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 @@ -644,6 +644,10 @@ sealed class Route { val replyTo: HexKey? = null, ) : Route() + @Serializable data class RelayGroupServer( + val relayUrl: String, + ) : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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 bc3c9e700f..77df693cf3 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 @@ -1710,6 +1710,8 @@ class AccountViewModel( 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/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..825b75d997 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.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.chats.publicChannels.relayGroup + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.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.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.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +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.RelayGroupDirectorySubscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.delay + +/** + * 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 + * [RelayGroupDirectorySubscription] and consumed into per-group channels; this + * screen reads them back for the relay and renders them. + */ +@Composable +fun RelayGroupChannelListScreen( + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + + RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) + + // The channel set grows as directory events arrive; poll the cache while the + // screen is on top (a directory is small and rarely changes). + val channels by produceState( + initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay), + relay, + ) { + while (true) { + value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() } + delay(1500) + } + } + + Scaffold( + topBar = { + TopBarExtensibleWithBackButton( + title = { + Text( + text = relay.displayUrl(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + popBack = nav::popBack, + ) + }, + ) { padding -> + LazyColumn(modifier = Modifier.padding(padding)) { + items(channels, key = { it.groupId.id }) { channel -> + RelayGroupChannelRow(channel) { nav.nav(routeFor(channel)) } + HorizontalDivider(thickness = 0.25.dp) + } + } + } +} + +@Composable +private fun RelayGroupChannelRow( + channel: RelayGroupChannel, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = "# " + channel.toBestDisplayName(), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + channel.summary()?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt new file mode 100644 index 0000000000..e39e019fdc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.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 RelayGroupDirectoryQueryState( + 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 RelayGroupDirectoryFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayGroupDirectorySubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class RelayGroupDirectorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupDirectoryQueryState, + 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: RelayGroupDirectoryQueryState) = key.relay +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt new file mode 100644 index 0000000000..c33473ae25 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.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 RelayGroupDirectorySubscription( + relay: NormalizedRelayUrl, + dataSource: RelayGroupDirectoryFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account, relay) { + RelayGroupDirectoryQueryState(relay, accountViewModel.account) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} From 34c1e4b6bb4a89a9028b4294c079835a0b15afb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:51:52 +0000 Subject: [PATCH 10/81] feat(nip29): inline group rows in Messages list + view-mode setting (C, part 3) The default "inline" Messages view: joined NIP-29 channels appear as rows in the Messages list, each with a tappable chip naming its host relay. - RelayGroupViewMode setting (INLINE default / GROUPED) on AccountSettings. - ChatroomListKnownFeedFilter includes joined relay-group channels' latest messages in the flat feed when the mode is INLINE (excluded in GROUPED, where they'll be reached via relay rows). - ChatroomHeaderCompose resolves a relay-group row via the note's channel gatherer (like Marmot groups) and renders RelayGroupRoomCompose: name + a relay chip. Row tap opens the chat (Route.RelayGroup); chip tap opens that relay's channel list (Route.RelayGroupServer). Remaining: the Settings toggle + persistence for the view mode, and the GROUPED mode's relay rows. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/model/AccountSettings.kt | 2 + .../chats/rooms/ChatroomHeaderCompose.kt | 90 +++++++++++++++++++ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 24 ++++- .../nip29RelayGroups/RelayGroupViewMode.kt | 41 +++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupViewMode.kt 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 04a004eba1..7b9b76f693 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -26,6 +26,7 @@ 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 @@ -272,6 +273,7 @@ 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 { 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..9258f10f84 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,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +import androidx.compose.foundation.clickable 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.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 +39,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 +47,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 +55,7 @@ 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.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -80,6 +89,7 @@ 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 @@ -138,6 +148,12 @@ private fun ChatroomEntry( return } + val relayGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } + if (relayGroup != null) { + RelayGroupRoomCompose(lastMessage, relayGroup, accountViewModel, nav) + return + } + val baseNoteEvent = lastMessage.event when (baseNoteEvent) { // ChannelMessageEvent and ChannelMetadataEvent both expose channelId() via @@ -299,6 +315,80 @@ 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 { + "" + } + + 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)) }, + ) +} + +/** 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), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + @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..cf77e26ffd 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,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +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 +30,13 @@ 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.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 class ChatroomListKnownFeedFilter( val account: Account, @@ -89,7 +92,26 @@ class ChatroomListKnownFeedFilter( } } - return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups).toSet()) + // NIP-29 relay groups the user joined (kind 10009) appear inline in the + // Messages list (the "inline" view mode), each row tagged with its host + // relay. In the "grouped" mode they're reached via relay rows instead, so + // they're excluded from this flat feed. + val relayGroups = + if (account.settings.relayGroupViewMode.value == RelayGroupViewMode.INLINE) { + account.relayGroupList.liveRelayGroupList.value.mapNotNull { groupTag -> + val relay = RelayUrlNormalizer.normalizeOrNull(groupTag.relayUrl) ?: return@mapNotNull null + LocalCache + .getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay)) + .notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() + } + } else { + emptyList() + } + + return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups).toSet()) } override fun updateListWith( 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 + } +} From d5d412597822b83ace2f152b8104e4a3a2e45acd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:59:08 +0000 Subject: [PATCH 11/81] feat(nip29): grouped view mode + view-mode toggle & persistence (C, part 4) Completes the Messages-tab integration and makes the view mode user-switchable. - RelayGroupServerList: the GROUPED mode's relay rows (one per host relay of the user's joined groups), tap opens the relay's channel list; rendered above the DM feed in MessagesSinglePane only in GROUPED mode (MessagesPager gains a modifier param so it weights correctly under the section). - RelayGroupViewModeToggle: a segmented Inline/By-relay control, shown once the user has joined a group; flips AccountSettings.relayGroupViewMode. - Persistence: the view mode is saved/restored via LocalPreferences (mirrors defaultRelayAuthPolicy), with an updateRelayGroupViewMode setter that saves. The full NIP-29 relay-groups feature is now navigable end-to-end: browse a relay's channels, chat in a group, and see joined groups in the Messages tab in either inline (default) or grouped view. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/LocalPreferences.kt | 5 + .../amethyst/model/AccountSettings.kt | 7 ++ .../relayGroup/RelayGroupServerList.kt | 97 +++++++++++++++++++ .../relayGroup/RelayGroupViewModeToggle.kt | 77 +++++++++++++++ .../chats/rooms/feed/ChatroomListTabs.kt | 3 +- .../rooms/singlepane/MessagesSinglePane.kt | 27 ++++-- amethyst/src/main/res/values/strings.xml | 2 + 7 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 02bba66e51..32eb9c41f2 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 @@ -159,6 +160,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" @@ -512,6 +514,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 @@ -632,6 +635,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() @@ -839,6 +843,7 @@ object LocalPreferences { hideNIP17WarningDialog = hideNIP17WarningDialog, alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), + relayGroupViewMode = MutableStateFlow(relayGroupViewMode), splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled), showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications), backupUserMetadata = latestUserMetadataResolved, 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 7b9b76f693..b59f2ed26b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -290,6 +290,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 // --- 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..f716456b7c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +/** + * The "grouped" Messages view: one row per host relay of the user's joined NIP-29 + * groups. Tapping a relay opens its channel list. Rendered above the DM feed only + * in [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED]. + */ +@Composable +fun RelayGroupServerList( + accountViewModel: AccountViewModel, + nav: INav, +) { + val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers + .collectAsStateWithLifecycle() + + if (servers.isEmpty()) return + + Column(Modifier.fillMaxWidth()) { + servers.sorted().forEach { server -> + RelayServerRow(server) { nav.nav(Route.RelayGroupServer(server)) } + HorizontalDivider(thickness = 0.25.dp) + } + } +} + +@Composable +private fun RelayServerRow( + relayUrl: String, + onClick: () -> Unit, +) { + val label = RelayUrlNormalizer.normalizeOrNull(relayUrl)?.displayUrl() ?: relayUrl + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = label, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "›", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt new file mode 100644 index 0000000000..b45d706b66 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt @@ -0,0 +1,77 @@ +/* + * 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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * A compact toggle to switch how the user's NIP-29 groups appear in the Messages + * tab — [RelayGroupViewMode.INLINE] (channels as rows with a relay chip) vs + * [RelayGroupViewMode.GROUPED] (a row per relay, drill in for its channels). + * Only shown once the user has joined at least one group. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RelayGroupViewModeToggle(accountViewModel: AccountViewModel) { + val groups by accountViewModel.account.relayGroupList.liveRelayGroupList + .collectAsStateWithLifecycle() + if (groups.isEmpty()) return + + val mode by accountViewModel.account.settings.relayGroupViewMode + .collectAsStateWithLifecycle() + + SingleChoiceSegmentedButtonRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + SegmentedButton( + selected = mode == RelayGroupViewMode.INLINE, + onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { + Text(stringRes(R.string.relay_group_view_inline)) + } + SegmentedButton( + selected = mode == RelayGroupViewMode.GROUPED, + onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { + Text(stringRes(R.string.relay_group_view_grouped)) + } + } +} 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..36f16f387d 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 @@ -26,7 +26,10 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -38,6 +41,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.RelayGroupServerList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle 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 +103,21 @@ fun MessagesSinglePane( }, accountViewModel = accountViewModel, ) { - MessagesPager( - pagerState, - tabs, - accountViewModel, - nav, - ) + val viewMode by accountViewModel.account.settings.relayGroupViewMode + .collectAsStateWithLifecycle() + + Column { + RelayGroupViewModeToggle(accountViewModel) + if (viewMode == RelayGroupViewMode.GROUPED) { + RelayGroupServerList(accountViewModel, nav) + } + MessagesPager( + pagerState, + tabs, + accountViewModel, + nav, + modifier = Modifier.weight(1f), + ) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bd4ffe9016..1dd8d15966 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1929,6 +1929,8 @@ Public Group New Public or Private Group + Inline + By relay Private To Subject From 5f677280d8c48b4c42a019e6d5dbd20b451e0cf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:12:11 +0000 Subject: [PATCH 12/81] feat(nip29): join / leave / create / invite actions Wire the NIP-29 membership + admin flows into the UI, all published only to the group's host relay: - Account: joinRelayGroup (9021 + follow), leaveRelayGroup (9022 + unfollow), createRelayGroup (9007 + 9002, returns the new GroupId), createRelayGroupInvite (9009); AccountViewModel wrappers. - RelayGroupTopBar: a Join button when not a member; once joined, an overflow with Invite + Leave. Membership is read from the kind-10009 list. - CreateRelayGroupDialog: name/topic/private/invite-only; mints a random group id, publishes, and navigates into the new channel. Reachable from a FAB on the relay channel-list screen. - InviteRelayGroupDialog: mints a kind-9009 invite code on open and offers copy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 66 +++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 21 +++ .../relayGroup/CreateRelayGroupDialog.kt | 130 ++++++++++++++++++ .../relayGroup/InviteRelayGroupDialog.kt | 88 ++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 14 ++ .../relayGroup/RelayGroupTopBar.kt | 53 +++++++ amethyst/src/main/res/values/strings.xml | 10 ++ 7 files changed, 382 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt 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 ccd4765b60..75872c0b92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -202,6 +202,13 @@ 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.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.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 @@ -1455,6 +1462,65 @@ class Account( 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, + isPrivate: Boolean = false, + isClosed: Boolean = false, + ): GroupId { + signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } + + val status = + buildSet { + add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) + add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + } + val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status) + signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } + + val id = GroupId(groupId, relay) + follow(LocalCache.getOrCreateRelayGroupChannel(id)) + return id + } + + /** 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() } + } + 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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 77df693cf3..7a4bce7efe 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 @@ -1431,6 +1431,27 @@ 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?, + isPrivate: Boolean, + isClosed: Boolean, + ) = launchSigner { account.createRelayGroup(relay, groupId, name, about, isPrivate, isClosed) } + + fun createRelayGroupInvite( + channel: RelayGroupChannel, + code: String, + ) = launchSigner { account.createRelayGroupInvite(channel, code) } + fun follow(users: List) = launchSigner { account.follow(users) } fun follow(user: User) = launchSigner { account.follow(user) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt new file mode 100644 index 0000000000..71a6038c08 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt @@ -0,0 +1,130 @@ +/* + * 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.Row +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.Switch +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.unit.dp +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.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.utils.RandomInstance + +/** Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada). */ +private fun randomGroupId(): String = RandomInstance.bytes(8).toHexKey() + +/** + * Create a NIP-29 group on [relay]: publishes kind 9007 + 9002 with the chosen + * name/visibility, then navigates into the new group. The user becomes its first + * admin. + */ +@Composable +fun CreateRelayGroupDialog( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + var about by remember { mutableStateOf("") } + var isPrivate by remember { mutableStateOf(false) } + var isClosed by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_create_title)) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_create_name)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = about, + onValueChange = { about = it }, + label = { Text(stringRes(R.string.relay_group_create_topic)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + ToggleRow(stringRes(R.string.relay_group_create_private), isPrivate) { isPrivate = it } + ToggleRow(stringRes(R.string.relay_group_create_invite_only), isClosed) { isClosed = it } + } + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { + val groupId = randomGroupId() + accountViewModel.createRelayGroup( + relay = relay, + groupId = groupId, + name = name.trim(), + about = about.trim().ifBlank { null }, + isPrivate = isPrivate, + isClosed = isClosed, + ) + onDismiss() + nav.nav(Route.RelayGroup(groupId, relay.url)) + }, + ) { + Text(stringRes(R.string.relay_group_create_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} 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..49ef3021b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -0,0 +1,88 @@ +/* + * 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.setValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +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 +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * 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 = LocalClipboardManager.current + val code by remember { mutableStateOf(RandomInstance.bytes(6).toHexKey()) } + + // Publishing the invite is a one-shot side effect when the dialog opens. + LaunchedEffect(code) { + 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)) + Text( + text = code, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + }, + confirmButton = { + TextButton(onClick = { + clipboard.setText(AnnotatedString(code)) + 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/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 825b75d997..b75db2434f 100644 --- 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 @@ -26,14 +26,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.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.mutableStateOf import androidx.compose.runtime.produceState 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.style.TextOverflow @@ -62,6 +65,8 @@ fun RelayGroupChannelListScreen( ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + var showCreate by remember { mutableStateOf(false) } + RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) // The channel set grows as directory events arrive; poll the cache while the @@ -90,6 +95,11 @@ fun RelayGroupChannelListScreen( popBack = nav::popBack, ) }, + floatingActionButton = { + FloatingActionButton(onClick = { showCreate = true }) { + Text("+", style = MaterialTheme.typography.headlineMedium) + } + }, ) { padding -> LazyColumn(modifier = Modifier.padding(padding)) { items(channels, key = { it.groupId.id }) { channel -> @@ -98,6 +108,10 @@ fun RelayGroupChannelListScreen( } } } + + if (showCreate) { + CreateRelayGroupDialog(relay, accountViewModel, nav) { showCreate = false } + } } @Composable 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 index 0c6302f02b..10eb845c57 100644 --- 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 @@ -21,17 +21,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.layout.Column +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.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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel 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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Composable fun RelayGroupTopBar( @@ -43,6 +54,17 @@ fun RelayGroupTopBar( val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList + .collectAsStateWithLifecycle() + val isJoined = + joinedGroups.any { + it.groupId == channel.groupId.id && + RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == channel.groupId.relayUrl + } + + var menuOpen by remember { mutableStateOf(false) } + var showInvite by remember { mutableStateOf(false) } + TopBarExtensibleWithBackButton( title = { Column { @@ -61,6 +83,37 @@ fun RelayGroupTopBar( ) } }, + actions = { + if (!isJoined) { + FilledTonalButton(onClick = { accountViewModel.joinRelayGroup(channel) }) { + Text(stringRes(R.string.join)) + } + } else { + IconButton(onClick = { menuOpen = true }) { + Text("⋮", style = MaterialTheme.typography.titleLarge) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + 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 } + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1dd8d15966..1bccd67f01 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1931,6 +1931,16 @@ New Public or Private Group Inline By relay + Create a channel + Channel name + Topic (optional) + Private (members-only read) + Invite only + Create + Invite people + Share this code so people can join this channel. + Creating an invite… + Invite code copied Private To Subject From e8a2f0cdac5b3375430a866594107ec0c5135581 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:13:54 +0000 Subject: [PATCH 13/81] feat(nip29): two-pane Messages parity for relay-group views Bring the tablet/wide two-pane Messages layout to parity with single-pane: the view-mode toggle and the GROUPED relay rows now render above the chatroom list in the first pane (inline group rows already flowed through the shared feed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../chats/rooms/twopane/MessagesTwoPane.kt | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) 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..7be553bb1d 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 @@ -21,21 +21,26 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -46,6 +51,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.RelayGroupServerList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -92,12 +99,23 @@ fun MessagesTwoPane( TwoPane( first = { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) + val viewMode by accountViewModel.account.settings.relayGroupViewMode + .collectAsStateWithLifecycle() + + Column(Modifier.fillMaxSize()) { + RelayGroupViewModeToggle(accountViewModel) + if (viewMode == RelayGroupViewMode.GROUPED) { + RelayGroupServerList(accountViewModel, nav) + } + Box(Modifier.weight(1f).fillMaxWidth()) { + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + } + } Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { ChannelFabColumn(nav) From 7c0f24ef573706b373341be7fa10585a4f74218c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:31:57 +0000 Subject: [PATCH 14/81] feat(nip29): accurate membership state from the relay roster Membership is now derived from the relay's own signed lists (kind 39001 admins / 39002 members) instead of the client's kind-10009 intent: - RelayGroupChannel gains members/admins (from 39002/39001), a RelayGroupMembership derivation (ADMIN/MODERATOR/MEMBER/NONE, + a client-side PENDING), and a member count. LocalCache consumes 39001/39002 into the channel. - RelayGroupTopBar shows the real state: member count and your role in the subtitle; a Join button when you're not a member, an optimistic "Requested" after you tap Join (until the relay's roster confirms), and Invite (mods only) + Leave once you're in. Invite is gated on moderate rights. Caveat: private groups may hide 39002 from non-members, so state resolves after the roster is visible; a targeted 9000/9001 subscription could tighten that later. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/model/LocalCache.kt | 38 ++++++ .../relayGroup/RelayGroupTopBar.kt | 109 ++++++++++++------ amethyst/src/main/res/values/strings.xml | 8 ++ .../nip29RelayGroups/RelayGroupChannel.kt | 48 ++++++++ .../nip29RelayGroups/RelayGroupMembership.kt | 60 ++++++++++ 5 files changed, 230 insertions(+), 33 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt 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 286796875a..bb26b588e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -155,6 +155,8 @@ 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.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -1758,6 +1760,34 @@ object LocalCache : ILocalCache, ICacheProvider { 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 @@ -3600,6 +3630,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is GroupMembersEvent -> { + consume(event, relay, wasVerified) + } + + is GroupAdminsEvent -> { + consume(event, relay, wasVerified) + } + is ExternalIdentitiesEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } 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 index 10eb845c57..a9a92f1909 100644 --- 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 @@ -28,21 +28,23 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R 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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @Composable fun RelayGroupTopBar( @@ -50,17 +52,21 @@ fun RelayGroupTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - // Recompose the title when the relay-signed metadata (name) arrives/changes. + // Recompose when the relay-signed metadata / roster changes. val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel - val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList - .collectAsStateWithLifecycle() - val isJoined = - joinedGroups.any { - it.groupId == channel.groupId.id && - RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == channel.groupId.relayUrl - } + 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 + } + val displayMembership = if (!membership.isMember() && requested) RelayGroupMembership.PENDING else membership var menuOpen by remember { mutableStateOf(false) } var showInvite by remember { mutableStateOf(false) } @@ -75,7 +81,7 @@ fun RelayGroupTopBar( overflow = TextOverflow.Ellipsis, ) Text( - text = channel.groupId.relayUrl.url, + text = subtitle(channel.groupId.relayUrl.displayUrl(), memberCount, displayMembership), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -84,30 +90,47 @@ fun RelayGroupTopBar( } }, actions = { - if (!isJoined) { - FilledTonalButton(onClick = { accountViewModel.joinRelayGroup(channel) }) { - Text(stringRes(R.string.join)) - } - } else { - IconButton(onClick = { menuOpen = true }) { - Text("⋮", style = MaterialTheme.typography.titleLarge) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - 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) - }, + when { + displayMembership == RelayGroupMembership.PENDING -> { + Text( + text = stringRes(R.string.relay_group_pending), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + + !displayMembership.isMember() -> { + FilledTonalButton(onClick = { + requested = true + accountViewModel.joinRelayGroup(channel) + }) { + Text(stringRes(R.string.join)) + } + } + + else -> { + IconButton(onClick = { menuOpen = true }) { + Text("⋮", style = MaterialTheme.typography.titleLarge) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + 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, @@ -117,3 +140,23 @@ fun RelayGroupTopBar( InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } } } + +@Composable +private fun subtitle( + relayHost: String, + memberCount: Int, + membership: RelayGroupMembership, +): String { + val parts = mutableListOf(relayHost) + if (memberCount > 0) parts.add(pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)) + val role = + when (membership) { + RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) + RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) + RelayGroupMembership.MEMBER -> stringRes(R.string.relay_group_role_member) + RelayGroupMembership.PENDING -> stringRes(R.string.relay_group_pending) + RelayGroupMembership.NONE -> null + } + role?.let { parts.add(it) } + return parts.joinToString(" · ") +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1bccd67f01..10eba0acd7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1941,6 +1941,14 @@ Share this code so people can join this channel. Creating an invite… Invite code copied + Requested + Admin + Moderator + Member + + %1$d member + %1$d members + Private To Subject 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 index 3c61880574..5cacbad479 100644 --- 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 @@ -23,8 +23,12 @@ 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.quartz.nip01Core.core.HexKey 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 /** * A NIP-29 relay-based group ("channel" in Discord terms). Unlike a NIP-C7 @@ -49,6 +53,16 @@ class RelayGroupChannel( var updatedMetadataAt: Long = 0 + /** 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 + /** A relay group lives on exactly one relay: its host. */ override fun relays() = setOf(groupId.relayUrl) @@ -80,6 +94,40 @@ class RelayGroupChannel( updateChannelInfo() } + fun updateMembers(event: GroupMembersEvent) { + if (event.createdAt < membersUpdatedAt) return + members = event.members().toSet() + membersUpdatedAt = event.createdAt + updateChannelInfo() + } + + fun updateAdmins(event: GroupAdminsEvent) { + if (event.createdAt < adminsUpdatedAt) return + admins = event.admins() + adminsUpdatedAt = event.createdAt + updateChannelInfo() + } + + /** Number of known members (admins are members too). */ + fun memberCount(): Int = (members + admins.map { it.pubKey }).size + + /** + * 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 + admin.roles.any { it.equals(RelayGroupMembership.ROLE_MODERATOR, true) } -> RelayGroupMembership.MODERATOR + else -> RelayGroupMembership.MEMBER + } + } + 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 || 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" + } +} From 2dca3069145a886ba13b8e97115abe3b324c9c52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:36:07 +0000 Subject: [PATCH 15/81] feat(nip29): shareable group naddr + invite-code join flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nostr-native invites/deep-links for relay groups (no proprietary URL scheme): - RelayGroupChannel.toNAddr(): a NIP-19 `naddr` for the group's kind-39000 metadata (authored by the relay key, host relay as hint) — a cross-client coordinate that opens the group. - ClickableRoute: a clicked/rendered `naddr` for kind 39000 routes straight into the group chat (Route.RelayGroup with the relay hint) instead of the generic addressable-note view. - InviteRelayGroupDialog now shares the group `nostr:naddr…` (for discovery) plus the one-time code for closed groups; copy grabs both. - JoinRelayGroupDialog: closed groups prompt for the invite code, which the join request (9021) carries; open groups still join in one tap. Follow-up: cold-start `nostr:naddr` deep links (from outside the app) still route to the generic note view; only the in-app clicked/rendered path is wired here. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/components/ClickableRoute.kt | 16 ++++ .../relayGroup/InviteRelayGroupDialog.kt | 37 ++++++-- .../relayGroup/JoinRelayGroupDialog.kt | 87 +++++++++++++++++++ .../relayGroup/RelayGroupTopBar.kt | 19 +++- amethyst/src/main/res/values/strings.xml | 5 ++ .../nip29RelayGroups/RelayGroupChannel.kt | 11 +++ 6 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt 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/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt index 49ef3021b1..1d607806b3 100644 --- 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 @@ -59,23 +59,46 @@ fun InviteRelayGroupDialog( accountViewModel.createRelayGroupInvite(channel, code) } + // 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 = channel.toNAddr()?.let { "nostr:$it" } + val isClosed = channel.isClosed() + AlertDialog( onDismissRequest = onDismiss, title = { Text(stringRes(R.string.relay_group_invite_title)) }, text = { Column { Text(stringRes(R.string.relay_group_invite_description)) - Text( - text = code, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, - ) + + nAddr?.let { + Text( + text = it, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + ) + } + + // 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 = { TextButton(onClick = { - clipboard.setText(AnnotatedString(code)) + // Copy the most useful thing: the group link, plus the code when + // the group is closed (so a recipient has both to join). + val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n") + clipboard.setText(AnnotatedString(toCopy.ifBlank { code })) onDismiss() }) { Text(stringRes(R.string.copy)) 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/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index a9a92f1909..1bd4b24aeb 100644 --- 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 @@ -70,6 +70,7 @@ fun RelayGroupTopBar( var menuOpen by remember { mutableStateOf(false) } var showInvite by remember { mutableStateOf(false) } + var showJoinCode by remember { mutableStateOf(false) } TopBarExtensibleWithBackButton( title = { @@ -101,8 +102,13 @@ fun RelayGroupTopBar( !displayMembership.isMember() -> { FilledTonalButton(onClick = { - requested = true - accountViewModel.joinRelayGroup(channel) + // 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)) } @@ -139,6 +145,15 @@ fun RelayGroupTopBar( if (showInvite) { InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } } + + if (showJoinCode) { + JoinRelayGroupDialog( + channel = channel, + accountViewModel = accountViewModel, + onJoined = { requested = true }, + onDismiss = { showJoinCode = false }, + ) + } } @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 10eba0acd7..f92ef7c9e3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1941,6 +1941,11 @@ Share this code so people can join this channel. Creating an invite… Invite code copied + Invite code (for this invite-only channel): + Join channel + Invite code + This channel is invite-only. Enter the code you were given. + Join Requested Admin Moderator 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 index 5cacbad479..e9c4136b9b 100644 --- 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note 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 @@ -108,6 +109,16 @@ class RelayGroupChannel( 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 = (members + admins.map { it.pubKey }).size From 7eefdd2fb5d67e36d3de5d01ba2fc30435e47445 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:45:50 +0000 Subject: [PATCH 16/81] feat(nip29): visual polish pass on the relay-group UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain, glyph-based rows with real iconography and avatars (all icons reuse the existing MaterialSymbols subset — no font regen): - Relay rows (grouped view): the relay's NIP-11 avatar + name, host subtitle, and a ChevronRight icon instead of a "›" character. - Channel-browse rows: a channel avatar, a private-lock glyph, member count, and a "joined" check; the FAB uses the Add icon instead of a "+" character. - Chat top bar: a real MoreVert overflow icon; the header now shows a colored role pill (Admin/Moderator/Requested), a members count with a Group icon, and a lock for private groups — replacing the flat "host · N members · role" text. - Inline row's relay chip gains a small Dns icon. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupChannelListScreen.kt | 101 ++++++++++++--- .../relayGroup/RelayGroupServerList.kt | 59 ++++++--- .../relayGroup/RelayGroupTopBar.kt | 117 ++++++++++++++---- .../chats/rooms/ChatroomHeaderCompose.kt | 27 ++-- 4 files changed, 238 insertions(+), 66 deletions(-) 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 index b75db2434f..7f353934bf 100644 --- 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 @@ -21,11 +21,16 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -37,16 +42,25 @@ 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.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.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.RelayGroupDirectorySubscription +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.delay @@ -97,14 +111,19 @@ fun RelayGroupChannelListScreen( }, floatingActionButton = { FloatingActionButton(onClick = { showCreate = true }) { - Text("+", style = MaterialTheme.typography.headlineMedium) + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_create_title), + modifier = Modifier.size(24.dp), + ) } }, ) { padding -> + val myPubkey = accountViewModel.userProfile().pubkeyHex LazyColumn(modifier = Modifier.padding(padding)) { items(channels, key = { it.groupId.id }) { channel -> - RelayGroupChannelRow(channel) { nav.nav(routeFor(channel)) } - HorizontalDivider(thickness = 0.25.dp) + RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -117,29 +136,77 @@ fun RelayGroupChannelListScreen( @Composable private fun RelayGroupChannelRow( channel: RelayGroupChannel, + myPubkey: String, + accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - Column( + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val joined = channel.membershipOf(myPubkey).isMember() + val memberCount = channel.memberCount() + + Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = "# " + channel.toBestDisplayName(), - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + 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, ) - channel.summary()?.takeIf { it.isNotBlank() }?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + + 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/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index f716456b7c..1cae9ff857 100644 --- 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 @@ -26,6 +26,7 @@ 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.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,16 +38,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl /** * The "grouped" Messages view: one row per host relay of the user's joined NIP-29 - * groups. Tapping a relay opens its channel list. Rendered above the DM feed only - * in [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED]. + * groups, each with the relay's NIP-11 avatar and name. Tapping a relay opens its + * channel list. Rendered above the DM feed only in + * [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED]. */ @Composable fun RelayGroupServerList( @@ -60,8 +66,8 @@ fun RelayGroupServerList( Column(Modifier.fillMaxWidth()) { servers.sorted().forEach { server -> - RelayServerRow(server) { nav.nav(Route.RelayGroupServer(server)) } - HorizontalDivider(thickness = 0.25.dp) + RelayServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -69,29 +75,52 @@ fun RelayGroupServerList( @Composable private fun RelayServerRow( relayUrl: String, + accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - val label = RelayUrlNormalizer.normalizeOrNull(relayUrl)?.displayUrl() ?: relayUrl + 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 = 14.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = label, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), + RenderRelayIcon( + displayUrl = host, + iconUrl = info?.value?.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + pingInMs = 0, ) - Text( - text = "›", - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 1bd4b24aeb..d0d1fd455d 100644 --- 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 @@ -20,12 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +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 @@ -33,10 +39,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.res.pluralStringResource +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 @@ -75,19 +85,53 @@ fun RelayGroupTopBar( TopBarExtensibleWithBackButton( title = { Column { - Text( - text = channel.toBestDisplayName(), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = subtitle(channel.groupId.relayUrl.displayUrl(), memberCount, displayMembership), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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 = { @@ -116,7 +160,11 @@ fun RelayGroupTopBar( else -> { IconButton(onClick = { menuOpen = true }) { - Text("⋮", style = MaterialTheme.typography.titleLarge) + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), + ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { if (displayMembership.canModerate()) { @@ -156,22 +204,37 @@ fun RelayGroupTopBar( } } +/** A small colored pill naming the user's role/status in the group. */ @Composable -private fun subtitle( - relayHost: String, - memberCount: Int, - membership: RelayGroupMembership, -): String { - val parts = mutableListOf(relayHost) - if (memberCount > 0) parts.add(pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)) - val role = +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.MEMBER -> stringRes(R.string.relay_group_role_member) RelayGroupMembership.PENDING -> stringRes(R.string.relay_group_pending) - RelayGroupMembership.NONE -> null + // A plain member needs no badge; the lack of a Join button already says it. + RelayGroupMembership.MEMBER, RelayGroupMembership.NONE -> return } - role?.let { parts.add(it) } - return parts.joinToString(" · ") + + 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/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 9258f10f84..5c4c86b7e8 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 @@ -21,10 +21,12 @@ 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 @@ -378,14 +380,25 @@ private fun RelayNameChip( color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.clickable(onClick = onClick), ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + 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, + ) + } } } From 21387569e910caa62dc7a43ea2344c314d1ee865 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:56:21 +0000 Subject: [PATCH 17/81] feat: add NIP-29 group roster and admin moderation UI Adds a members roster screen and admin/moderator actions for relay-based groups: - Account: removeRelayGroupUser (kind 9001), putRelayGroupUser (kind 9000 promote/demote), editRelayGroupMetadata (kind 9002), all pinned to the group's host relay; plus AccountViewModel wrappers. - RelayGroupMembersScreen: relay-signed roster (39001 admins / 39002 members) with avatars, names and role badges. Moderators get a per-user menu to promote to admin/moderator, remove a role, or kick (with a confirm dialog). Menu items are gated so a moderator can't act on an admin and nobody acts on themselves. - EditRelayGroupDialog: admin-only edit of name/topic/visibility. - Wire Members, Edit channel (admin) and existing Invite/Leave into the chat top bar overflow; new Route.RelayGroupMembers registration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 41 +++ .../amethyst/ui/navigation/AppNavigation.kt | 10 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../ui/screen/loggedIn/AccountViewModel.kt | 19 ++ .../relayGroup/EditRelayGroupDialog.kt | 118 +++++++ .../relayGroup/RelayGroupMembersScreen.kt | 302 ++++++++++++++++++ .../relayGroup/RelayGroupTopBar.kt | 22 ++ amethyst/src/main/res/values/strings.xml | 14 + 8 files changed, 531 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt 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 75872c0b92..2ba61c755d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -207,6 +207,8 @@ 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 @@ -1521,6 +1523,45 @@ class Account( 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?, + isPrivate: Boolean, + isClosed: Boolean, + ) { + val status = + buildSet { + add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) + add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + } + val template = EditMetadataEvent.build(channel.groupId.id, name = name, about = about, status = status) + 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/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ee39529e56..4ad8e382ed 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 @@ -107,6 +107,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen 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.RelayGroupMembersScreen 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 @@ -576,6 +577,15 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupMembersScreen( + id = it.id, + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } 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 957963f2fc..003dd924cc 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 @@ -648,6 +648,11 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable data class RelayGroupMembers( + val id: String, + val relayUrl: String, + ) : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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 7a4bce7efe..76b498def2 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 @@ -1452,6 +1452,25 @@ class AccountViewModel( code: String, ) = launchSigner { account.createRelayGroupInvite(channel, code) } + 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?, + isPrivate: Boolean, + isClosed: Boolean, + ) = launchSigner { account.editRelayGroupMetadata(channel, name, about, isPrivate, isClosed) } + fun follow(users: List) = launchSigner { account.follow(users) } fun follow(user: User) = launchSigner { account.follow(user) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt new file mode 100644 index 0000000000..f658198990 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt @@ -0,0 +1,118 @@ +/* + * 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.Row +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.Switch +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.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 + +/** + * Edit a NIP-29 group's relay-signed metadata (kind 9002). Pre-filled from the + * channel's current metadata; the host relay only honours it from an admin. + */ +@Composable +fun EditRelayGroupDialog( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var name by remember(channel.groupId) { mutableStateOf(channel.event?.name() ?: "") } + var about by remember(channel.groupId) { mutableStateOf(channel.event?.about() ?: "") } + var isPrivate by remember(channel.groupId) { mutableStateOf(channel.isPrivate()) } + var isClosed by remember(channel.groupId) { mutableStateOf(channel.isClosed()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_edit_title)) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_edit_name)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = about, + onValueChange = { about = it }, + label = { Text(stringRes(R.string.relay_group_edit_topic)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + EditToggleRow(stringRes(R.string.relay_group_edit_private), isPrivate) { isPrivate = it } + EditToggleRow(stringRes(R.string.relay_group_edit_invite_only), isClosed) { isClosed = it } + } + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { + accountViewModel.editRelayGroupMetadata( + channel = channel, + name = name.trim(), + about = about.trim().ifBlank { null }, + isPrivate = isPrivate, + isClosed = isClosed, + ) + onDismiss() + }, + ) { + Text(stringRes(R.string.relay_group_edit_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun EditToggleRow( + label: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} 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..aaa56543db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -0,0 +1,302 @@ +/* + * 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.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.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.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.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, +) { + // 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 = { + Text( + text = stringRes(R.string.relay_group_members_title), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + popBack = nav::popBack, + ) + }, + ) { padding -> + LazyColumn(modifier = Modifier.padding(padding)) { + items(roster, key = { it.pubkey }) { entry -> + RelayGroupMemberRow( + entry = entry, + channel = channel, + isSelf = entry.pubkey == myPubkey, + viewerCanModerate = iCanModerate, + viewerIsAdmin = iAmAdmin, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } +} + +/** 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, +) { + val user = remember(entry.pubkey) { accountViewModel.getUserIfExists(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/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index d0d1fd455d..928574d21d 100644 --- 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 @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann 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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -81,6 +82,7 @@ fun RelayGroupTopBar( var menuOpen by remember { mutableStateOf(false) } var showInvite by remember { mutableStateOf(false) } var showJoinCode by remember { mutableStateOf(false) } + var showEdit by remember { mutableStateOf(false) } TopBarExtensibleWithBackButton( title = { @@ -167,6 +169,22 @@ fun RelayGroupTopBar( ) } 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 + showEdit = true + }, + ) + } if (displayMembership.canModerate()) { DropdownMenuItem( text = { Text(stringRes(R.string.relay_group_invite_title)) }, @@ -194,6 +212,10 @@ fun RelayGroupTopBar( InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } } + if (showEdit) { + EditRelayGroupDialog(channel, accountViewModel) { showEdit = false } + } + if (showJoinCode) { JoinRelayGroupDialog( channel = channel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f92ef7c9e3..da102bd701 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1950,6 +1950,20 @@ Admin Moderator Member + Members + Make admin + Make moderator + Remove role + Remove from channel + Remove %1$s from this channel? They will lose access until re-added or re-invited. + Edit channel + Channel name + Topic (optional) + Private (members-only read) + Invite only + Save + Members + Edit channel %1$d member %1$d members From d6b76c400fb73bc7ca90e612a6b518911b1a8159 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:22:20 +0000 Subject: [PATCH 18/81] feat: make relay groups discoverable, shareable and deep-linkable Turns NIP-29 groups from a joined-only feature into something users can find, share and open from a link: - RelayGroupBrowseScreen (Route.RelayGroupBrowse): paste any relay URL to browse the channels it hosts, pick a relay you're already on, or try a popular public relay. Reached from a new "Find channels" FAB entry and from the grouped server list. - RelayGroupServerList always shows a "Find channels" row (even with no joined groups), so a new user has a starting point instead of an empty view; extracted RelayGroupServerRow for reuse by the browse screen. - Share action in the group top bar: fires the system share sheet with a njump link to the group's naddr, available to members and non-members. - Cold-start deep links: a kind-39000 group naddr now routes straight to the group chat using the naddr's relay hint, instead of falling through to the generic note-redirect screen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/ui/MainActivity.kt | 22 ++- .../amethyst/ui/navigation/AppNavigation.kt | 8 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../relayGroup/RelayGroupBrowseScreen.kt | 169 ++++++++++++++++++ .../relayGroup/RelayGroupServerList.kt | 39 +++- .../relayGroup/RelayGroupTopBar.kt | 30 ++++ .../loggedIn/chats/rooms/ChannelFabColumn.kt | 19 ++ amethyst/src/main/res/values/strings.xml | 6 + 8 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt 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..68fab013a4 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,7 @@ 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.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent @@ -230,10 +231,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 +313,15 @@ 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) +} 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 4ad8e382ed..b7ab28a34f 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,7 @@ 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.RelayGroupMembersScreen @@ -586,6 +587,13 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupBrowseScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } 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 003dd924cc..4201f5b486 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 @@ -653,6 +653,8 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable object RelayGroupBrowse : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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..48100715db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt @@ -0,0 +1,169 @@ +/* + * 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.Row +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.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.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.stringRes +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 [RelayGroupServerList] can't offer. + */ +@Composable +fun RelayGroupBrowseScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var relayUrl by remember { mutableStateOf("") } + + val joined by accountViewModel.account.relayGroupList.liveRelayGroupServers + .collectAsStateWithLifecycle() + + fun open(url: String) { + val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) ?: return + 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), + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + OutlinedTextField( + value = relayUrl, + onValueChange = { relayUrl = it }, + singleLine = true, + 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.weight(1f), + ) + } + + 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) + } + } + + val suggestions = POPULAR_RELAYS.filter { it !in joined } + 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/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index 1cae9ff857..4e49307b8b 100644 --- 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 @@ -38,6 +38,7 @@ 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.model.nip11RelayInfo.loadRelayInfo @@ -45,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -62,18 +64,47 @@ fun RelayGroupServerList( val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers .collectAsStateWithLifecycle() - if (servers.isEmpty()) return - Column(Modifier.fillMaxWidth()) { servers.sorted().forEach { server -> - RelayServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) } + RelayGroupServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) } HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } + + // Always offer a discovery entry, so a user with no groups yet still has + // somewhere to start. + FindChannelsRow { nav.nav(Route.RelayGroupBrowse) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } @Composable -private fun RelayServerRow( +private fun FindChannelsRow(onClick: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.relay_group_browse_title), + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +/** One relay row: NIP-11 avatar + name, used by the grouped list and the browse screen. */ +@Composable +fun RelayGroupServerRow( relayUrl: String, accountViewModel: AccountViewModel, onClick: () -> Unit, 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 index 928574d21d..0d464c76af 100644 --- 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 @@ -20,6 +20,8 @@ */ 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 @@ -41,6 +43,7 @@ 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 @@ -53,6 +56,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe 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 @@ -137,6 +141,17 @@ fun RelayGroupTopBar( } }, actions = { + 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( @@ -226,6 +241,21 @@ fun RelayGroupTopBar( } } +/** 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) { 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index da102bd701..eff9d71e78 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1964,6 +1964,12 @@ Save Members Edit channel + Find channels + Browse the channels hosted on a group relay. Paste a relay address, or pick one below. + Relay address + Browse + Relays you\'re on + Popular relays %1$d member %1$d members From f7be77c6e73ae6407eefc7a3d274aa6c58efa1c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:04:00 +0000 Subject: [PATCH 19/81] test: cover NIP-29 client-side membership and private-list round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds runnable jvmTest coverage for the commons logic behind relay groups, which until now was only compile-verified: - RelayGroupChannelTest: the roster fold in RelayGroupChannel — role derivation (admin/moderator/plain member), an admin present only in the 39001 list still resolving as a member, member-count dedup across admins+members, and the createdAt supersede guards dropping stale out-of-order 39000/39002 events. - RelayGroupListDecryptionTest: drives the exact create/add/remove calls join/leave delegate to, then reads them back through the decryption cache, proving a followed group survives the NIP-44 encrypt→sign→decrypt round-trip and that unfollow removes only the intended group. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../nip29RelayGroups/RelayGroupChannelTest.kt | 167 ++++++++++++++++++ .../RelayGroupListDecryptionTest.kt | 83 +++++++++ 2 files changed, 250 insertions(+) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionTest.kt 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..ba992c399f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -0,0 +1,167 @@ +/* + * 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.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 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 adminWithoutKnownRoleIsPlainMember() { + val c = channel() + c.updateAdmins(admins(100, alice to listOf("ceo"))) + assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(alice)) + } + + @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()) + } +} 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..cdea377148 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupListDecryptionTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.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 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) + } +} From 7ffc38f55d29ffa49b46754f8aaa1f7b00e52737 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:25:27 +0000 Subject: [PATCH 20/81] feat: promote Relay Groups to a top-level destination + live rosters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the feature to "Relay Groups" and makes it a first-class navigation destination rather than a Messages-tab sub-view, and keeps joined-group membership accurate app-wide. - Top-level Route.RelayGroups home (RelayGroupsHomeScreen): a root destination with the drawer top bar + bottom bar, listing the host relays of joined groups (each drilling into its channels) plus the discovery entry. Registered as NavBarItem.RELAY_GROUPS (Forum icon) in the drawer's Feeds section and pinnable to the bottom bar, with a roster preloader entry. - Naming: user-facing strings now say "group(s)" instead of "channel(s)"; added relay_groups_title = "Relay Groups". - Live rosters: RelayGroupRosterFilterAssembler keeps every joined group's 39000/39001/39002 fresh (one #d-scoped filter per host relay, re-derived from the join list) while a groups-bearing screen is on top. Mounted on both Messages panes and the new home, so membership, pending→member transitions and member counts stay accurate without opening each chat — the gap that most affected closed/private groups. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../RelaySubscriptionsCoordinator.kt | 3 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 9 ++ .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/BottomBarFeedPreloaders.kt | 3 + .../relayGroup/RelayGroupsHomeScreen.kt | 85 ++++++++++++++ .../RelayGroupRosterFilterAssembler.kt | 106 ++++++++++++++++++ .../RelayGroupRosterSubscription.kt | 45 ++++++++ .../rooms/singlepane/MessagesSinglePane.kt | 5 + .../chats/rooms/twopane/MessagesTwoPane.kt | 3 + amethyst/src/main/res/values/strings.xml | 27 ++--- 11 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt 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 fc25678611..7902367100 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 @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.Calend 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.RelayGroupDirectoryFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler 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 @@ -115,6 +116,7 @@ class RelaySubscriptionsCoordinator( // active depending on the screen. val channel = ChannelFilterAssembler(client) val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client) + val relayGroupRoster = RelayGroupRosterFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -177,6 +179,7 @@ class RelaySubscriptionsCoordinator( val all = listOf( relayGroupDirectory, + relayGroupRoster, account, accountForeground, home, 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 b7ab28a34f..44401ca49e 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 @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG 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.RelayGroupMembersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupsHomeScreen 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 @@ -371,6 +372,7 @@ fun BuildNavigation( composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { PublicChatsScreen(accountViewModel, nav) } + composableFromEnd { RelayGroupsHomeScreen(accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, 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/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 4201f5b486..4554235a43 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 @@ -653,6 +653,8 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable object RelayGroups : Route() + @Serializable object RelayGroupBrowse : Route() @Serializable data class ChannelMetadataEdit( 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..bc5becb547 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.RelayGroupRosterSubscription 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 -> RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, 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/relayGroup/RelayGroupsHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt new file mode 100644 index 0000000000..315e022816 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt @@ -0,0 +1,85 @@ +/* + * 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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +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.datasource.RelayGroupRosterSubscription +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * The top-level "Relay Groups" destination — one tap from the nav drawer / bottom + * bar, distinct from the DM-centric Messages tab. Lists the host relays of the + * groups the user has joined (each drilling into that relay's channels) and offers + * the discovery entry to find more. Keeps every joined group's roster live while + * on top via [RelayGroupRosterSubscription]. + */ +@Composable +fun RelayGroupsHomeScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + UserDrawerSearchTopBar(accountViewModel, nav) { + Text( + text = stringRes(R.string.relay_groups_title), + fontWeight = FontWeight.Bold, + ) + } + }, + bottomBar = { + AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route -> + nav.navBottomBar(route) + } + }, + accountViewModel = accountViewModel, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + // Joined relays (each drills into its channels), followed by the + // always-present "Find groups" discovery row. + RelayGroupServerList(accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterFilterAssembler.kt new file mode 100644 index 0000000000..1195adf960 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterFilterAssembler.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 RelayGroupRosterQueryState( + 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 RelayGroupRosterFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayGroupRosterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class RelayGroupRosterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupRosterQueryState, + 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: RelayGroupRosterQueryState) = key.account +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt new file mode 100644 index 0000000000..8b747bd0c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt @@ -0,0 +1,45 @@ +/* + * 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 + +/** + * 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 + * assembler re-derives its per-relay filters from the joined-group list, so it + * follows joins/leaves automatically. + */ +@Composable +fun RelayGroupRosterSubscription( + dataSource: RelayGroupRosterFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + RelayGroupRosterQueryState(accountViewModel.account) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} 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 36f16f387d..3409f6db6a 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 @@ -43,6 +43,7 @@ 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.RelayGroupServerList import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription 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 @@ -106,6 +107,10 @@ fun MessagesSinglePane( val viewMode by accountViewModel.account.settings.relayGroupViewMode .collectAsStateWithLifecycle() + // Keep joined groups' rosters live while the messages list is on top, so + // membership/pending state is accurate inline without opening each chat. + RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) + Column { RelayGroupViewModeToggle(accountViewModel) if (viewMode == RelayGroupViewMode.GROUPED) { 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 7be553bb1d..d40f9f0a04 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 @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomVie import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupServerList import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.theme.Size20dp @@ -102,6 +103,8 @@ fun MessagesTwoPane( val viewMode by accountViewModel.account.settings.relayGroupViewMode .collectAsStateWithLifecycle() + RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) + Column(Modifier.fillMaxSize()) { RelayGroupViewModeToggle(accountViewModel) if (viewMode == RelayGroupViewMode.GROUPED) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eff9d71e78..648650f782 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1929,22 +1929,23 @@ Public Group New Public or Private Group + Relay Groups Inline By relay - Create a channel - Channel name + Create a group + Group name Topic (optional) Private (members-only read) Invite only Create Invite people - Share this code so people can join this channel. + Share this code so people can join this group. Creating an invite… Invite code copied - Invite code (for this invite-only channel): - Join channel + Invite code (for this invite-only group): + Join group Invite code - This channel is invite-only. Enter the code you were given. + This group is invite-only. Enter the code you were given. Join Requested Admin @@ -1954,18 +1955,18 @@ Make admin Make moderator Remove role - Remove from channel - Remove %1$s from this channel? They will lose access until re-added or re-invited. - Edit channel - Channel name + 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 channel - Find channels - Browse the channels hosted on a group relay. Paste a relay address, or pick one below. + Edit group + Find groups + Browse the groups hosted on a relay. Paste a relay address, or pick one below. Relay address Browse Relays you\'re on From d85bac1f55c73013d527b3c7f40db6782fa2e5de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:41:15 +0000 Subject: [PATCH 21/81] =?UTF-8?q?feat(cli):=20amy=20relaygroup=20=E2=80=94?= =?UTF-8?q?=20NIP-29=20relay=20groups=20in=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a first-class `amy relaygroup` verb group so the CLI can drive the same NIP-29 relay groups as the app. Thin assembly over quartz builders + Context.publish/drain — no protocol logic in cli/. Verbs: - list / browse RELAY / info RELAY GID — reads (joined kind:10009 list with private-item decryption; a relay's 39000-39003 directory; one group's metadata + roster). - create / join / leave / message — lifecycle. join/leave also maintain the caller's kind:10009 list (add/remove private item) so `list` reflects them, mirroring the app's follow/unfollow. - edit / invite / put-user / remove-user — moderation (9002/9009/9000/9001). All writes pin to the group's single host relay. Output follows amy's text/--json contract with snake_case keys. Verified end-to-end against an embedded relay (amy serve) with a new self-contained harness, cli/tests/relaygroup/relaygroup-headless.sh: create/message/join/list/browse all pass (5/5). browse/info return empty against geode since it doesn't sign 39000-39003 — a relay capability, not a client issue. README command table + ROADMAP updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- cli/README.md | 21 ++ cli/ROADMAP.md | 8 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 18 ++ .../cli/commands/RelayGroupCommands.kt | 256 ++++++++++++++++++ .../commands/RelayGroupModerationCommands.kt | 132 +++++++++ .../cli/commands/RelayGroupReadCommands.kt | 167 ++++++++++++ cli/tests/.gitignore | 1 + cli/tests/relaygroup/relaygroup-headless.sh | 127 +++++++++ 8 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt create mode 100755 cli/tests/relaygroup/relaygroup-headless.sh diff --git a/cli/README.md b/cli/README.md index 13da5dd96d..f3d07048a6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -412,6 +412,27 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `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] [--closed]` | Edit metadata (9002, admin only). | +| `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 6e5a7cd979..d34696864a 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -120,10 +120,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/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index fc85fa7494..a67f2a5fe1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -58,6 +58,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.StoreCommands @@ -206,6 +207,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) @@ -567,6 +569,22 @@ 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] [--closed] + | 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..52da4563e4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -0,0 +1,256 @@ +/* + * 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) + + 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 }), + ), + ) + 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 + + val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1) + val current = + ctx + .drain(outbox.associateWith { listOf(filter) }, 5_000) + .map { it.second } + .filterIsInstance() + .maxByOrNull { it.createdAt } + + val tag = GroupTag(groupId, relay.url, null) + val updated = + when { + add && current == null -> SimpleGroupListEvent.create(privateGroups = listOf(tag), signer = ctx.signer) + add -> SimpleGroupListEvent.add(current!!, tag, isPrivate = true, 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 toggles. */ +internal fun groupStatus( + isPrivate: Boolean, + isClosed: Boolean, +): Set = + buildSet { + add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) + add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + } + +/** + * 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..4808a79c40 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -0,0 +1,132 @@ +/* + * 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.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] [--closed]` → 9002. */ + suspend fun edit( + dataDir: DataDir, + rest: Array, + ): Int = + publishScoped(dataDir, rest, "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]") { _, groupId, args -> + // Only touch visibility when the user actually passed a flag — otherwise + // leave name/about edits without asserting an (unknown) status. + val status = + if (args.bool("private") || args.bool("closed")) { + groupStatus(args.bool("private"), args.bool("closed")) + } else { + emptySet() + } + EditMetadataEvent.build(groupId, name = args.flag("name"), about = args.flag("about"), status = status) + } + + /** `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..e03a885e37 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt @@ -0,0 +1,167 @@ +/* + * 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.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() + val filter = Filter(kinds = listOf(GroupMetadataEvent.KIND), limit = 500) + val metas = + ctx + .drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000) + .map { it.second } + .filterIsInstance() + .distinctBy { it.groupId() } + .sortedBy { it.name()?.lowercase() ?: it.groupId() } + + Output.emit( + mapOf( + "relay" to relay.url, + "count" to 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 events = ctx.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000).map { it.second } + + 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 + } + } + + 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 3d5dc27cf2..bad5982cf2 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -3,3 +3,4 @@ marmot/state-headless/ dm/state-dm-headless/ nests/state/ clink/state-clink-headless/ +relaygroup/state-relaygroup-headless/ 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 From 633903b5c1d3aabf78fee289424feedbe7ebabbb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 02:27:18 +0000 Subject: [PATCH 22/81] =?UTF-8?q?fix:=20relay-group=20audit=20=E2=80=94=20?= =?UTF-8?q?timeline,=20roster,=20membership,=20list-safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes found in a full audit of the NIP-29 relay-groups feature across quartz/commons/amethyst/cli. Correctness (app): - Own group messages never appeared in the timeline until an app restart: the optimistic send is consumed with a null relay, so attachToRelayGroup bailed on the relay==null guard, and the host relay's echo (new==false) was skipped by the "only attach when newly consumed" gate. Attach now runs on every arrival, gated on the note being loaded, and the null-relay case attaches to the already-open channel(s) for that group id. Also avoids the wrong-relay phantom by only fabricating a channel from real provenance. - Roster subscription was frozen after an in-place join/leave (state keyed on the stable account, never re-derived); it now invalidates on every liveRelayGroupList change, so a fresh join's 39002 admission is fetched. - membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER, hiding moderation; presence in the admins list now means at least MODERATOR. - Members roster showed permanent truncated-hex names (one-shot getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay fills in when kind:0 arrives. Protocol / data: - GroupTag had no value equality → joined-group Sets never deduped and the StateFlow re-emitted on every identical re-arrival. Equality is now the (id, relay) pair, excluding the cosmetic name. - create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29 flags are presence-only, so only private/closed are emitted when set. - Metadata/member/admin supersede guards use <= so an equal-createdAt duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now private-set. Relay-group channels are now included in the prune loops. CLI: - join/leave/create updated the kind:10009 list from a network-only drain; a slow/empty fetch could publish a fresh list containing ONLY the new group, wiping the rest. Now reads the local store (source of truth) too. - edit re-asserted both visibility axes from flag presence, so --closed on a private group leaked it public. It now reads current 39000 and merges, with --public/--open counter-flags; only the specified axis changes. - create now tracks the new group in kind:10009 (parity with join/Android). UI polish: - Invite dialog no longer mints a 9009 for open groups and won't copy a code it never displayed. Browse "popular" list normalizes URLs before filtering. Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt no-resupersede added; all quartz+commons NIP-29 suites and the amy relaygroup harness pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 12 ++- .../amethyst/model/LocalCache.kt | 38 +++++++-- .../relayGroup/InviteRelayGroupDialog.kt | 31 ++++--- .../relayGroup/RelayGroupBrowseScreen.kt | 5 +- .../relayGroup/RelayGroupMembersScreen.kt | 5 +- .../RelayGroupRosterSubscription.kt | 17 +++- cli/README.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 5 +- .../cli/commands/RelayGroupCommands.kt | 24 +++++- .../commands/RelayGroupModerationCommands.kt | 83 ++++++++++++++++--- .../nip29RelayGroups/RelayGroupChannel.kt | 19 +++-- .../nip29RelayGroups/RelayGroupChannelTest.kt | 20 ++++- .../nip51Lists/simpleGroupList/GroupTag.kt | 11 +++ .../Nip29ArmadaInteropTest.kt | 15 ++++ 14 files changed, 235 insertions(+), 52 deletions(-) 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 2ba61c755d..eb796e9eeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1503,8 +1503,10 @@ class Account( val status = buildSet { - add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) - add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + // NIP-29 flags are presence-only: public/open is the absence of the + // private/closed tags, so emit only the restrictive flags that are on. + if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) + if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) } val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status) signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } @@ -1555,8 +1557,10 @@ class Account( ) { val status = buildSet { - add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) - add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + // NIP-29 flags are presence-only: public/open is the absence of the + // private/closed tags, so emit only the restrictive flags that are on. + if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) + if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) } val template = EditMetadataEvent.build(channel.groupId.id, name = name, about = about, status = status) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } 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 bb26b588e7..54f30ade19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1800,9 +1800,25 @@ object LocalCache : ILocalCache, ICacheProvider { relay: NormalizedRelayUrl?, ) { val groupId = event.groupId() ?: return - if (relay == null) return - val channel = getOrCreateRelayGroupChannel(GroupId(groupId, relay)) - channel.addNote(getOrCreateNote(event.id), relay) + 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 to every already-open channel with + // this group id — normally the exact room being composed in — so the + // message appears immediately. Don't fabricate a channel from a guessed + // relay; the host relay's later echo attaches it to the canonical key. + relayGroupChannels.filter { key, _ -> key.id == groupId }.forEach { it.addNote(note, null) } + } } fun consume( @@ -2739,6 +2755,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 @@ -2789,6 +2809,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 @@ -3993,13 +4017,17 @@ object LocalCache : ILocalCache, ICacheProvider { is ChatEvent -> { consumeRegularEvent(event, relay, wasVerified).also { - if (it) attachToRelayGroupIfScoped(event, relay) + // 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).also { - if (it) attachToRelayGroupIfScoped(event, relay) + attachToRelayGroupIfScoped(event, relay) } } 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 index 1d607806b3..ffd6cab72c 100644 --- 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 @@ -54,16 +54,18 @@ fun InviteRelayGroupDialog( val clipboard = LocalClipboardManager.current val code by remember { mutableStateOf(RandomInstance.bytes(6).toHexKey()) } - // Publishing the invite is a one-shot side effect when the dialog opens. - LaunchedEffect(code) { - accountViewModel.createRelayGroupInvite(channel, code) - } - // 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 = channel.toNAddr()?.let { "nostr:$it" } val isClosed = channel.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)) }, @@ -94,13 +96,18 @@ fun InviteRelayGroupDialog( } }, confirmButton = { - TextButton(onClick = { - // Copy the most useful thing: the group link, plus the code when - // the group is closed (so a recipient has both to join). - val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n") - clipboard.setText(AnnotatedString(toCopy.ifBlank { code })) - onDismiss() - }) { + // 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 = { + clipboard.setText(AnnotatedString(toCopy)) + onDismiss() + }, + ) { Text(stringRes(R.string.copy)) } }, 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 index 48100715db..0c4d8707ba 100644 --- 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 @@ -145,7 +145,10 @@ fun RelayGroupBrowseScreen( } } - val suggestions = POPULAR_RELAYS.filter { it !in joined } + // `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 -> 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 index aaa56543db..cc58adb8bb 100644 --- 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 @@ -167,7 +167,10 @@ private fun RelayGroupMemberRow( accountViewModel: AccountViewModel, nav: INav, ) { - val user = remember(entry.pubkey) { accountViewModel.getUserIfExists(entry.pubkey) } + // 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt index 8b747bd0c2..c497a5151a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt @@ -21,15 +21,22 @@ 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 - * assembler re-derives its per-relay filters from the joined-group list, so it - * follows joins/leaves automatically. + * 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 RelayGroupRosterSubscription( @@ -41,5 +48,9 @@ fun RelayGroupRosterSubscription( RelayGroupRosterQueryState(accountViewModel.account) } + val joined by accountViewModel.account.relayGroupList.liveRelayGroupList + .collectAsStateWithLifecycle() + LaunchedEffect(joined) { dataSource.invalidateFilters() } + LifecycleAwareKeyDataSourceSubscription(state, dataSource) } diff --git a/cli/README.md b/cli/README.md index f3d07048a6..ae3ad7ed48 100644 --- a/cli/README.md +++ b/cli/README.md @@ -428,7 +428,7 @@ screen speaks. | `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] [--closed]` | Edit metadata (9002, admin only). | +| `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). | 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 a67f2a5fe1..49157a3c10 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -578,8 +578,9 @@ private fun printUsage() { | 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] [--closed] + | 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] 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 index 52da4563e4..849392ddc7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -94,6 +94,9 @@ object RelayGroupCommands { 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( @@ -103,6 +106,7 @@ object RelayGroupCommands { "private" to isPrivate, "closed" to isClosed, "published" to (createAck.values.any { it } && editAck.values.any { it }), + "listed" to listed, ), ) return 0 @@ -192,13 +196,20 @@ private suspend fun updateGroupList( 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 current = + 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) val updated = @@ -212,14 +223,19 @@ private suspend fun updateGroupList( return ctx.publish(updated, outbox).values.any { it } } -/** The NIP-29 status flag set for the given visibility toggles. */ +/** + * 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 { - add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC) - add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN) + if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) + if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) } /** 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 index 4808a79c40..dd9bbcef09 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -24,6 +24,8 @@ 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 @@ -35,22 +37,83 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent * host relay via [publishScoped] or a direct publish. */ object RelayGroupModerationCommands { - /** `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]` → 9002. */ + /** + * `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 = - publishScoped(dataDir, rest, "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]") { _, groupId, args -> - // Only touch visibility when the user actually passed a flag — otherwise - // leave name/about edits without asserting an (unknown) status. - val status = - if (args.bool("private") || args.bool("closed")) { - groupStatus(args.bool("private"), args.bool("closed")) + ): 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 { - emptySet() + (meta?.isPrivate() ?: false) } - EditMetadataEvent.build(groupId, name = args.flag("name"), about = args.flag("about"), status = status) + val isClosed = + if (args.bool("closed")) { + true + } else if (args.bool("open")) { + false + } else { + (meta?.isClosed() ?: false) + } + + val signed = + ctx.signer.sign( + EditMetadataEvent.build( + groupId, + name = args.flag("name"), + about = args.flag("about"), + status = groupStatus(isPrivate, isClosed), + ), + ) + 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( 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 index e9c4136b9b..442ccaf47c 100644 --- 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 @@ -53,6 +53,7 @@ class RelayGroupChannel( var metadataNote: Note? = null var updatedMetadataAt: Long = 0 + private set /** Relay-signed member pubkeys (kind 39002). */ var members: Set = emptySet() @@ -87,8 +88,10 @@ class RelayGroupChannel( event: GroupMetadataEvent, eventNote: Note? = null, ) { - // Only newer metadata supersedes. - if (event.createdAt < updatedMetadataAt) return + // 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 @@ -96,14 +99,14 @@ class RelayGroupChannel( } fun updateMembers(event: GroupMembersEvent) { - if (event.createdAt < membersUpdatedAt) return + if (event.createdAt <= membersUpdatedAt) return members = event.members().toSet() membersUpdatedAt = event.createdAt updateChannelInfo() } fun updateAdmins(event: GroupAdminsEvent) { - if (event.createdAt < adminsUpdatedAt) return + if (event.createdAt <= adminsUpdatedAt) return admins = event.admins() adminsUpdatedAt = event.createdAt updateChannelInfo() @@ -132,8 +135,12 @@ class RelayGroupChannel( if (admin != null) { return when { admin.roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> RelayGroupMembership.ADMIN - admin.roles.any { it.equals(RelayGroupMembership.ROLE_MODERATOR, true) } -> RelayGroupMembership.MODERATOR - else -> RelayGroupMembership.MEMBER + // 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 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 index ba992c399f..4f84c6db22 100644 --- 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 @@ -105,10 +105,24 @@ class RelayGroupChannelTest { } @Test - fun adminWithoutKnownRoleIsPlainMember() { + 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"))) - assertEquals(RelayGroupMembership.MEMBER, c.membershipOf(alice)) + 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 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/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index 071ddaeb32..fcb3374944 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -36,6 +36,7 @@ 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 @@ -259,6 +260,20 @@ class Nip29ArmadaInteropTest { // ── 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] From 2fb6e3cb2fd553c850e0683bbadf6581dffdac32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:15:23 +0000 Subject: [PATCH 23/81] feat: relay-group threads (kind 11) as a secondary view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Flotilla interop gap where NIP-29 groups also carry kind-11 "threads" (forum-style posts) that Amethyst's chat-only room view dropped. Threads are a secondary surface, kept out of the kind-9 chat feed — a Threads button in the group top bar, mirroring Discord/Slack. - RelayGroupChannel: a separate `threads` collection (kind-11 notes) with a reactive StateFlow, distinct from the chat timeline. - LocalCache: attach kind-11 to channel.threads (same host-pinned + own-send null-relay routing as chat messages). - Host-pinned RelayGroupThreadsFilterAssembler (kinds 11 + 1111 scoped by `#h`), active only while a group's Threads screen is open; fetching the 1111 comments too means opening a thread has its replies already cached. - RelayGroupThreadsScreen lists a group's threads (title, author, preview, reply count) and opens each in the existing thread view (Route.Note) for the full comment tree — no bespoke detail screen needed. Members start a thread via NewRelayGroupThreadDialog → Account.postRelayGroupThread (ThreadEvent.build(body, title){ hTag }). - Route.RelayGroupThreads + a Forum icon in the group top bar. Verified: kind-11 with h + title round-trips and is queryable by #h against an embedded relay (the exact filter Flotilla uses); commons threads-collection test (dedup/flow/remove) passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 12 + .../amethyst/model/LocalCache.kt | 27 +++ .../RelaySubscriptionsCoordinator.kt | 3 + .../amethyst/ui/navigation/AppNavigation.kt | 10 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../relayGroup/NewRelayGroupThreadDialog.kt | 91 ++++++++ .../relayGroup/RelayGroupThreadsScreen.kt | 221 ++++++++++++++++++ .../relayGroup/RelayGroupTopBar.kt | 10 + .../RelayGroupThreadsFilterAssembler.kt | 84 +++++++ .../RelayGroupThreadsSubscription.kt | 42 ++++ amethyst/src/main/res/values/strings.xml | 7 + .../nip29RelayGroups/RelayGroupChannel.kt | 28 +++ .../nip29RelayGroups/RelayGroupChannelTest.kt | 20 ++ 14 files changed, 566 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt 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 eb796e9eeb..6e9c168b03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -203,6 +203,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.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 @@ -262,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 @@ -1516,6 +1518,16 @@ class Account( return id } + /** 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, 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 54f30ade19..76d0b18c1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -259,6 +259,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 @@ -1821,6 +1822,26 @@ object LocalCache : ILocalCache, ICacheProvider { } } + /** + * 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 { + relayGroupChannels.filter { key, _ -> key.id == groupId }.forEach { it.addThread(note) } + } + } + fun consume( event: LiveActivitiesChatMessageEvent, relay: NormalizedRelayUrl?, @@ -4031,6 +4052,12 @@ object LocalCache : ILocalCache, ICacheProvider { } } + is ThreadEvent -> { + consumeRegularEvent(event, relay, wasVerified).also { + attachThreadToRelayGroupIfScoped(event, relay) + } + } + is PollResponseEvent -> { consume(event, relay, wasVerified) } 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 7902367100..ec63cf70ce 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 @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource. import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupDirectoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadsFilterAssembler 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 @@ -117,6 +118,7 @@ class RelaySubscriptionsCoordinator( val channel = ChannelFilterAssembler(client) val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client) val relayGroupRoster = RelayGroupRosterFilterAssembler(client) + val relayGroupThreads = RelayGroupThreadsFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -180,6 +182,7 @@ class RelaySubscriptionsCoordinator( listOf( relayGroupDirectory, relayGroupRoster, + relayGroupThreads, account, accountForeground, home, 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 44401ca49e..3c9474a0cb 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 @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG 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.RelayGroupMembersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupsHomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen @@ -589,6 +590,15 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupThreadsScreen( + id = it.id, + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { RelayGroupBrowseScreen( accountViewModel = accountViewModel, 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 4554235a43..9b08794a4b 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 @@ -653,6 +653,11 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable data class RelayGroupThreads( + val id: String, + val relayUrl: String, + ) : Route() + @Serializable object RelayGroups : Route() @Serializable object RelayGroupBrowse : Route() 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 76b498def2..9724dc6313 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 @@ -1452,6 +1452,12 @@ class AccountViewModel( 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt new file mode 100644 index 0000000000..5718b36abd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.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.ui.screen.loggedIn.chats.publicChannels.relayGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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 + +/** + * Start a new kind-11 thread in the group: a required title plus a body. Publishes + * to the group's host relay (the relay accepts it only from a member). + */ +@Composable +fun NewRelayGroupThreadDialog( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var title by remember { mutableStateOf("") } + var body by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_thread_new)) }, + text = { + Column { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_thread_title_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text(stringRes(R.string.relay_group_thread_body_label)) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp).padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton( + enabled = title.isNotBlank() && body.isNotBlank(), + onClick = { + accountViewModel.postRelayGroupThread(channel, title.trim(), body.trim()) + onDismiss() + }, + ) { + Text(stringRes(R.string.relay_group_thread_post)) + } + }, + 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/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt new file mode 100644 index 0000000000..419eaac887 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -0,0 +1,221 @@ +/* + * 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.items +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.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.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.RelayGroupThreadsSubscription +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 + * [RelayGroupThreadsSubscription]; 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, +) { + RelayGroupThreadsSubscription(channel, accountViewModel.dataSources().relayGroupThreads, accountViewModel) + + val threads by channel.threads.collectAsStateWithLifecycle() + var showCompose by remember { mutableStateOf(false) } + + 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 = { + FloatingActionButton(onClick = { showCompose = true }) { + 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)) { + items(threads, key = { it.idHex }) { thread -> + ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + + if (showCompose) { + NewRelayGroupThreadDialog(channel, accountViewModel) { showCompose = false } + } +} + +@Composable +private fun ThreadRow( + thread: Note, + accountViewModel: AccountViewModel, + nav: INav, + onClick: () -> Unit, +) { + 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 replyCount = thread.replies.size + 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 index 0d464c76af..d7d615e020 100644 --- 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 @@ -141,6 +141,16 @@ fun RelayGroupTopBar( } }, 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt new file mode 100644 index 0000000000..c24145633e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.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 RelayGroupThreadsQueryState( + 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 RelayGroupThreadsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayGroupThreadsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class RelayGroupThreadsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupThreadsQueryState, + 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: RelayGroupThreadsQueryState) = key.channel.groupId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt new file mode 100644 index 0000000000..c462bc63bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.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 RelayGroupThreadsSubscription( + channel: RelayGroupChannel, + dataSource: RelayGroupThreadsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(channel.groupId) { + RelayGroupThreadsQueryState(channel) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 648650f782..87473009c7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1965,6 +1965,13 @@ Save Members Edit group + Threads + 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 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 index 442ccaf47c..e504a7fe66 100644 --- 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 @@ -30,6 +30,9 @@ 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 @@ -65,6 +68,31 @@ class RelayGroupChannel( private set private var adminsUpdatedAt: Long = 0 + /** + * 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 + + /** 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) + _threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L } + } + + fun removeThread(note: Note) { + if (!threadNotes.containsKey(note.idHex)) return + threadNotes.remove(note.idHex) + _threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L } + } + + fun threadCount(): Int = threadNotes.size() + /** A relay group lives on exactly one relay: its host. */ override fun relays() = setOf(groupId.relayUrl) 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 index 4f84c6db22..d8a56e5a27 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 @@ -178,4 +179,23 @@ class RelayGroupChannelTest { 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 }) + } } From e43c752a07682fdfca5b0aace7321ac912c3572b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:36:10 +0000 Subject: [PATCH 24/81] fix: keep group thread replies scoped to the group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replying to a NIP-29 group thread went through the shared NIP-22 comment composer, which built a plain kind-1111 comment with no group `h` tag and broadcast it to the author's outbox. Such a reply is not group content: the host relay rejects it and no other member — or other NIP-29 client like Flotilla — ever sees it, and for a private/closed group it leaks to unrelated relays. Both fixes are tightly guarded on the replied-to event being group-scoped, so ordinary comments are untouched: - CommentPostViewModel inherits the group's `h` tag from the event being replied to (covers replies to the kind-11 root and to nested 1111 comments — both route here). - The reply is published only to the group's host relay (the relay the thread was seen on) via signAndSendPrivatelyOrBroadcast, instead of the outbox-computing broadcast — so it reaches the group and never leaks. Verified: quartz test builds the reply as the composer does (CommentEvent.replyBuilder { hTag } over a kind-11 root) and asserts it is a 1111 carrying the group `h` tag and referencing the thread. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../nip22Comments/CommentPostViewModel.kt | 21 +++++++++++++++++ .../Nip29ArmadaInteropTest.kt | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) 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..77d54cbd59 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,20 @@ 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 groupHostRelays = + replyingTo + ?.takeIf { it.event?.isGroupScoped() == true } + ?.relays + ?.takeIf { it.isNotEmpty() } + if (anonymous) { accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) + } else if (groupHostRelays != null) { + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { groupHostRelays } } else { accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) } @@ -577,6 +592,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/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index fcb3374944..0d2bffb626 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -21,6 +21,8 @@ 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 @@ -332,6 +334,27 @@ class Nip29ArmadaInteropTest { assertFalse(put.any { it[0] == "previous" }) } + @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") From e4a6e13036633dd8ad57979a492e67e9e071d571 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 14:21:42 +0000 Subject: [PATCH 25/81] =?UTF-8?q?fix:=20nostrord=20interop=20=E2=80=94=20t?= =?UTF-8?q?hread=20titles=20and=20public=20group=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compatibility gaps found analyzing nostrord (a NIP-29 client): - Thread titles: NIP-7D (and Amethyst) use a `title` tag, but nostrord writes/reads `subject`, so neither showed the other's thread titles. ThreadEvent.title() now reads `title` OR `subject`; we still emit only the spec-correct `title`. - Joined-groups list (kind 10009): Amethyst wrote memberships as NIP-44 private items, but both reference clients (Flotilla, nostrord) store — and nostrord only READS — public `["group", id, relay]` tags, so an Amethyst user's groups were invisible to them. follow() (and the amy CLI) now write public tags. NIP-29 membership is already public via the relay's kind-39002 list, so this loses no real privacy; reads still merge any legacy private items so existing lists keep working. Tests: read title from title/subject (title wins; we emit title only); public group is a plain tag and still read through the cache; a mixed public+private list reads as both. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../cli/commands/RelayGroupCommands.kt | 7 +++++-- .../nip29RelayGroups/RelayGroupListState.kt | 16 +++++++++----- .../RelayGroupListDecryptionTest.kt | 21 +++++++++++++++++++ .../quartz/nip7DThreads/ThreadEvent.kt | 8 ++++++- .../Nip29ArmadaInteropTest.kt | 19 +++++++++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) 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 index 849392ddc7..1e880953b1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -212,10 +212,13 @@ private suspend fun updateGroupList( 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(privateGroups = listOf(tag), signer = ctx.signer) - add -> SimpleGroupListEvent.add(current!!, tag, isPrivate = true, signer = ctx.signer) + 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) } 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 index 3286b19b4d..45d6fc21bd 100644 --- 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 @@ -49,9 +49,9 @@ interface RelayGroupRepository { /** * 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 stored as - * `["group", id, relay, name?]` items, either public or NIP-44-encrypted private - * (encrypted to self). Mirrors + * 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( @@ -103,16 +103,22 @@ class RelayGroupListState( 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( - privateGroups = listOf(group), + publicGroups = listOf(group), signer = signer, ) } else { SimpleGroupListEvent.add( earlierVersion = relayGroupList, group = group, - isPrivate = true, + isPrivate = false, signer = signer, ) } 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 index cdea377148..30acd5af44 100644 --- 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 @@ -47,6 +47,27 @@ class RelayGroupListDecryptionTest { 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 { 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/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index 0d2bffb626..f5d3f54aae 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -334,6 +334,25 @@ class Nip29ArmadaInteropTest { 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 From f9b24156e8850d5f9526cf3488804d4cf492d544 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 16:15:31 +0000 Subject: [PATCH 26/81] feat(nip29): linkify group invite links inline and via deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognise the de-facto `'[?code=]` NIP-29 group invite link format used by Wisp and 0xchat, both inside rendered note content and as an external deep link, so tapping one opens the group. The URL detector correctly stops a host at the apostrophe (host names can't contain `'`), so the group id is torn off before classification. Rather than loosen the shared URL grammar — which would swallow prose possessives like `example.com's` — group links are recovered by peeking just past each relay URL the detector already found. This is cache-miss-only and costs nothing on notes without a `wss://` link. - quartz: GroupInviteLink.parse / suffixLength (+ tests) - commons: Urls.groupLinks, UrlParser peek, RichTextParser plumbing (atomic span through fixMissingSpaces, new RelayGroupLinkSegment) (+ tests) - amethyst: ClickableRelayGroupLink renderer + uriToRoute deep-link branch Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/ui/MainActivity.kt | 14 +++ .../ui/components/ClickableRelayGroupLink.kt | 80 ++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 4 + .../commons/richtext/RichTextParser.kt | 6 +- .../richtext/RichTextParserSegments.kt | 9 ++ .../amethyst/commons/richtext/UrlParser.kt | 40 ++++++ .../richtext/RichTextParserGroupLinkTest.kt | 102 ++++++++++++++++ .../nip29RelayGroups/GroupInviteLink.kt | 109 +++++++++++++++++ .../nip29RelayGroups/GroupInviteLinkTest.kt | 114 ++++++++++++++++++ 9 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt 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 68fab013a4..6bd9833387 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,7 @@ 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 @@ -205,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) @@ -325,3 +328,14 @@ private fun relayGroupDirectRoute(nip19: NAddress): Route? { 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) +} 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..081ae833b7 --- /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)) + }, + ) + } + + 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/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 6114b369ed..79222f213d 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 -> ClickableRelayGroupLink(word.segmentText, nav) is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } 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/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..453e3d9def --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt @@ -0,0 +1,102 @@ +/* + * 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()) + } +} 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..298b32301f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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 == '_' + + 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 + + // 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.isEmpty() || !groupId.all { isIdChar(it) }) 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/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..b8decd1751 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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 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)) + } +} From 9b7d2f7e45baa48454fa315fc428e8328c3f965b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:10:26 +0000 Subject: [PATCH 27/81] feat(nip29): render inline group links as self-loading group cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the preview render path, a `'` link now draws as a full-width group card instead of a bare link. The card renders immediately with a stable layout — 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 in place as the relay-signed metadata arrives, without changing the card's structure or height. While a card is on screen it warms the group via a lightweight, host-pinned compose subscription: keeps kind 39000/39001/39002 + roles live so the card stays current, and prefetches the newest ~15 chat messages / threads so tapping the card opens an already-populated screen. Non-preview contexts (e.g. DMs) keep the plain clickable link and issue no outbound subscription. - RelayGroupPreviewFilterAssembler / RelayGroupPreviewSubscription (registered in RelaySubscriptionsCoordinator) - RelayGroupCard + RichTextViewer preview-path branch - relay_group_open string Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../RelaySubscriptionsCoordinator.kt | 3 + .../amethyst/ui/components/RelayGroupCard.kt | 170 ++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 2 +- .../RelayGroupPreviewFilterAssembler.kt | 95 ++++++++++ .../RelayGroupPreviewSubscription.kt | 42 +++++ amethyst/src/main/res/values/strings.xml | 1 + 6 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt 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 ec63cf70ce..1751ac47cb 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 @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.Calend 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.RelayGroupDirectoryFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler @@ -119,6 +120,7 @@ class RelaySubscriptionsCoordinator( val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client) val relayGroupRoster = RelayGroupRosterFilterAssembler(client) val relayGroupThreads = RelayGroupThreadsFilterAssembler(client) + val relayGroupPreview = RelayGroupPreviewFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -183,6 +185,7 @@ class RelaySubscriptionsCoordinator( relayGroupDirectory, relayGroupRoster, relayGroupThreads, + relayGroupPreview, account, accountForeground, home, 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..9a00bf6899 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -0,0 +1,170 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +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.routeFor +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.RelayGroupPreviewSubscription +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, accountViewModel, nav) + } +} + +@Composable +private fun RelayGroupCardContent( + baseChannel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, 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() + + // Relay host is known immediately; the member count fills in when the roster loads. + // Both render on the same single line so nothing reflows as the count arrives. + val relayLabel = channel.groupId.relayUrl.displayUrl() + val subtitle = + if (memberCount > 0) { + "$relayLabel · ${pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)}" + } else { + relayLabel + } + + OutlinedCard( + onClick = { nav.nav(routeFor(channel)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = channel.groupId.id, + model = channel.profilePicture(), + contentDescription = channel.toBestDisplayName(), + modifier = Modifier.size(48.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, + modifier = Modifier.weight(1f, fill = false), + ) + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = stringRes(R.string.relay_group_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.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 79222f213d..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 @@ -572,7 +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 -> ClickableRelayGroupLink(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/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt new file mode 100644 index 0000000000..cfcac1c20f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.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 RelayGroupPreviewQueryState( + val channel: RelayGroupChannel, +) + +/** Newest content kinds we prefetch so opening the card lands on populated screens. */ +private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = + listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND) + +/** How many recent events to pull ahead of a tap — enough to fill the first screen, no more. */ +private const val RELAY_GROUP_PREVIEW_LIMIT = 15 + +/** + * 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 RelayGroupPreviewFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayGroupPreviewSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class RelayGroupPreviewSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupPreviewQueryState, + since: SincePerRelayMap?, + ): List { + val groupId = key.channel.groupId + return filterMetadataToRelayGroup(key.channel, since) + + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = RELAY_GROUP_PREVIEW_CONTENT_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + limit = RELAY_GROUP_PREVIEW_LIMIT, + since = since?.get(groupId.relayUrl)?.time, + ), + ) + } + + override fun id(key: RelayGroupPreviewQueryState) = key.channel.groupId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt new file mode 100644 index 0000000000..bc1a4f8cdf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.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 an inline group-link card to warm its metadata + recent content. */ +@Composable +fun RelayGroupPreviewSubscription( + channel: RelayGroupChannel, + dataSource: RelayGroupPreviewFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(channel.groupId) { + RelayGroupPreviewQueryState(channel) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 87473009c7..89b4b1797c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1966,6 +1966,7 @@ Members Edit group Threads + Open group No threads yet. Start one with the + button. New thread Untitled From 2b11dbd7d9a32dcfb8a43ef07d6e5601da78d889 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:21:15 +0000 Subject: [PATCH 28/81] test(nip29): cover weird apostrophe placements + guard relay-URL possessives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject a single-character group id (except the default `_`) so a possessive glued to a bare relay URL — `wss://relay.damus.io's uptime` — no longer linkifies group "s". Real ids (relay29/Wisp/0xchat) are all longer. Adds coverage proving only genuine ws/wss relay URLs are peeked: apostrophes after http, nostr:, blossom:, email and bech32 tokens never become group links; plus ws:// (insecure), second-apostrophe boundary, and multi-link cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../richtext/RichTextParserGroupLinkTest.kt | 66 +++++++++++++++++++ .../nip29RelayGroups/GroupInviteLink.kt | 13 +++- .../nip29RelayGroups/GroupInviteLinkTest.kt | 25 +++++++ 3 files changed, 103 insertions(+), 1 deletion(-) 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 index 453e3d9def..2d34e019d3 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt @@ -99,4 +99,70 @@ class RichTextParserGroupLinkTest { 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/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt index 298b32301f..e2b62e8fc6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt @@ -47,6 +47,16 @@ data class GroupInviteLink( // 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=" /** @@ -64,6 +74,7 @@ data class GroupInviteLink( 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)) { @@ -89,7 +100,7 @@ data class GroupInviteLink( val rest = link.substring(apos + 1) val queryIdx = rest.indexOf('?') val groupId = if (queryIdx < 0) rest else rest.substring(0, queryIdx) - if (groupId.isEmpty() || !groupId.all { isIdChar(it) }) return null + if (!groupId.all { isIdChar(it) } || !isAcceptableGroupId(groupId)) return null val code = if (queryIdx >= 0) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt index b8decd1751..032d99896a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt @@ -77,6 +77,31 @@ class GroupInviteLinkTest { 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. From 51299400ef2f58ac5aca8cf2ede5a4fcdd273dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 18:05:00 +0000 Subject: [PATCH 29/81] feat(nip29): one-tap join from invite links carrying a code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invite link's `?code=` now flows all the way to the join: tapping a `wss://relay'id?code=…` card/link (or opening it as a deep link) opens the group and, because the tap is itself the opt-in, fires the kind-9021 join with that code once — no more re-typing it into the join dialog. Plain (code-less) group links still just open the group for viewing. - Route.RelayGroup gains inviteCode; threaded through AppNavigation → RelayGroupChatScreen → RelayGroupTopBar - RelayGroupTopBar auto-joins once when a code is present and we're not already a member (reuses the optimistic "requested" state) - RelayGroupCard / ClickableRelayGroupLink / uriToRoute carry the parsed code Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../com/vitorpamplona/amethyst/ui/MainActivity.kt | 2 +- .../ui/components/ClickableRelayGroupLink.kt | 2 +- .../amethyst/ui/components/RelayGroupCard.kt | 11 ++++++++--- .../amethyst/ui/navigation/AppNavigation.kt | 1 + .../amethyst/ui/navigation/routes/Routes.kt | 3 +++ .../relayGroup/RelayGroupChatScreen.kt | 3 ++- .../publicChannels/relayGroup/RelayGroupTopBar.kt | 14 ++++++++++++++ 7 files changed, 30 insertions(+), 6 deletions(-) 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 6bd9833387..caf3ea93d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -337,5 +337,5 @@ private fun relayGroupDirectRoute(nip19: NAddress): Route? { */ 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) + 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 index 081ae833b7..4e25814a33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt @@ -65,7 +65,7 @@ fun ClickableRelayGroupLink( } }, onClick = { - nav.nav(Route.RelayGroup(invite.groupId, invite.relayUrl.url)) + nav.nav(Route.RelayGroup(invite.groupId, invite.relayUrl.url, inviteCode = invite.code)) }, ) } 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 index 9a00bf6899..6c9c901422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -46,7 +46,7 @@ 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.routeFor +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.RelayGroupPreviewSubscription @@ -78,13 +78,14 @@ fun RelayGroupCard( val groupId = remember(invite) { invite.toGroupId() } LoadRelayGroupChannel(groupId, accountViewModel) { channel -> - RelayGroupCardContent(channel, accountViewModel, nav) + RelayGroupCardContent(channel, invite.code, accountViewModel, nav) } } @Composable private fun RelayGroupCardContent( baseChannel: RelayGroupChannel, + inviteCode: String?, accountViewModel: AccountViewModel, nav: INav, ) { @@ -111,7 +112,11 @@ private fun RelayGroupCardContent( } OutlinedCard( - onClick = { nav.nav(routeFor(channel)) }, + onClick = { + nav.nav( + Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url, inviteCode = inviteCode), + ) + }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), ) { Row( 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 3c9474a0cb..ae9b329d87 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 @@ -568,6 +568,7 @@ fun BuildNavigation( relayUrl = it.relayUrl, draftId = it.draftId, replyToId = it.replyTo, + inviteCode = it.inviteCode, accountViewModel = accountViewModel, nav = nav, ) 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 9b08794a4b..9bab7e56b8 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 @@ -642,6 +642,9 @@ sealed class Route { 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( 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 index 5bf356ce4f..8e3b894cd0 100644 --- 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 @@ -38,6 +38,7 @@ fun RelayGroupChatScreen( relayUrl: String, draftId: HexKey? = null, replyToId: HexKey? = null, + inviteCode: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -50,7 +51,7 @@ fun RelayGroupChatScreen( isInvertedLayout = true, topBar = { LoadRelayGroupChannel(channelId, accountViewModel) { - RelayGroupTopBar(it, accountViewModel, nav) + RelayGroupTopBar(it, inviteCode, accountViewModel, nav) } }, accountViewModel = accountViewModel, 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 index d7d615e020..ce63adbb11 100644 --- 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 @@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @Composable fun RelayGroupTopBar( baseChannel: RelayGroupChannel, + inviteCode: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -81,6 +82,19 @@ fun RelayGroupTopBar( 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) } From 7ea266f811799909c0505a047576d3b2133959dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 18:14:46 +0000 Subject: [PATCH 30/81] feat(nip29): show group description on the inline group card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group-link card now renders the group's about/description below the header (avatar + name + relay · members). The description occupies a reserved two-line block so it fills in when the relay-signed metadata arrives without changing the card's height — preserving the no-layout-shift behavior while making the card substantially richer than the bare URL. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/components/RelayGroupCard.kt | 94 +++++++++++-------- 1 file changed, 55 insertions(+), 39 deletions(-) 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 index 6c9c901422..4828b077f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -111,6 +111,10 @@ private fun RelayGroupCardContent( relayLabel } + // Description is reserved at a fixed two lines so it fills in when the metadata + // arrives without changing the card's height (empty for groups with no about). + val description = channel.summary()?.takeIf { it.isNotBlank() } ?: "" + OutlinedCard( onClick = { nav.nav( @@ -119,56 +123,68 @@ private fun RelayGroupCardContent( }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), ) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - RobohashFallbackAsyncImage( - robot = channel.groupId.id, - model = channel.profilePicture(), - contentDescription = channel.toBestDisplayName(), - modifier = Modifier.size(48.dp).clip(CircleShape), - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = autoPlayGif, - ) + 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(48.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), + 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, + modifier = Modifier.weight(1f, fill = false), ) } Text( - text = channel.toBestDisplayName(), - fontWeight = FontWeight.SemiBold, + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), ) } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = stringRes(R.string.relay_group_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), ) } - Icon( - symbol = MaterialSymbols.ChevronRight, - contentDescription = stringRes(R.string.relay_group_open), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) } } From 34dfd691d358f6410b542016a32cb1449ec90559 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 18:18:17 +0000 Subject: [PATCH 31/81] refactor(nip29): render group-card description only when present Drop the reserved two-line description block: description-less groups now stay compact instead of showing two blank lines. The about still fills in when the metadata loads (a small one-time grow), which is the better trade for the common case where a group has no description. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/components/RelayGroupCard.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) 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 index 4828b077f3..b0c6a6db30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -111,9 +111,9 @@ private fun RelayGroupCardContent( relayLabel } - // Description is reserved at a fixed two lines so it fills in when the metadata - // arrives without changing the card's height (empty for groups with no about). - val description = channel.summary()?.takeIf { it.isNotBlank() } ?: "" + // 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() } OutlinedCard( onClick = { @@ -177,15 +177,16 @@ private fun RelayGroupCardContent( ) } - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - minLines = 2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(top = 8.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), + ) + } } } } From 15c336e62306c21f58c87263bb167c2556698824 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 18:59:51 +0000 Subject: [PATCH 32/81] fix(nip29): live roster/metadata loading + empty states on group screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review-found reactivity/loading defects: - Members screen mounted no per-group roster subscription — observeChannel is a no-op for a RelayGroupChannel, so the roster only appeared if the chat screen had cached it. Mount RelayGroupPreviewSubscription so it fetches kinds 39001/39002 from the host relay while visible; add a loading state and the group name in the subtitle. - Invite dialog read toNAddr()/isClosed() as one-shot snapshots, so opening it before the kind-39000 arrived left the Copy button permanently disabled. Collect the metadata flow so the naddr/code fill in live; show a "Preparing invite…" placeholder meanwhile; move to the non-deprecated LocalClipboard. - Channel-list polled the cache every 1500ms and rendered a blank screen when empty. Drive refresh off LocalCache.observeEvents(kind 39000) instead of a timer, sort the initial value (no first-frame reshuffle), and add an empty state. Both lists now use leading dividers (no trailing divider under the last row). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/InviteRelayGroupDialog.kt | 36 +++++++--- .../relayGroup/RelayGroupChannelListScreen.kt | 45 +++++++++---- .../relayGroup/RelayGroupMembersScreen.kt | 67 +++++++++++++------ amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 110 insertions(+), 40 deletions(-) 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 index ffd6cab72c..e89cd689e1 100644 --- 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 @@ -30,16 +30,19 @@ 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.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +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 @@ -51,13 +54,23 @@ fun InviteRelayGroupDialog( accountViewModel: AccountViewModel, onDismiss: () -> Unit, ) { - val clipboard = LocalClipboardManager.current + 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 = channel.toNAddr()?.let { "nostr:$it" } - val isClosed = channel.isClosed() + 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 @@ -73,14 +86,21 @@ fun InviteRelayGroupDialog( Column { Text(stringRes(R.string.relay_group_invite_description)) - nAddr?.let { + if (nAddr != null) { Text( - text = it, + 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. @@ -104,7 +124,7 @@ fun InviteRelayGroupDialog( TextButton( enabled = toCopy.isNotBlank(), onClick = { - clipboard.setText(AnnotatedString(toCopy)) + scope.launch { clipboard.setText(toCopy) } onDismiss() }, ) { 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 index 7f353934bf..4d0a7acef8 100644 --- 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 @@ -25,11 +25,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -54,6 +55,7 @@ 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.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -61,9 +63,10 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupDirectorySubscription 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 kotlinx.coroutines.delay +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent /** * Lists every channel a relay hosts (its kind 39000-39003 directory), so the user @@ -83,16 +86,19 @@ fun RelayGroupChannelListScreen( RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) - // The channel set grows as directory events arrive; poll the cache while the - // screen is on top (a directory is small and rarely changes). + // 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 channels by produceState( - initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay), + initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }, relay, ) { - while (true) { - value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() } - delay(1500) - } + LocalCache + .observeEvents(Filter(kinds = listOf(GroupMetadataEvent.KIND))) + .collect { + value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() } + } } Scaffold( @@ -120,10 +126,23 @@ fun RelayGroupChannelListScreen( }, ) { padding -> val myPubkey = accountViewModel.userProfile().pubkeyHex - LazyColumn(modifier = Modifier.padding(padding)) { - items(channels, key = { it.groupId.id }) { channel -> - RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) } - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + 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)) } + } } } } 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 index cc58adb8bb..186893dede 100644 --- 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 @@ -21,15 +21,18 @@ 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.items +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 @@ -60,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack 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.RelayGroupPreviewSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -99,6 +103,12 @@ private fun RelayGroupMembers( 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. + RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel) + // Recompose when the relay-signed roster (39001/39002) changes. val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel @@ -121,29 +131,48 @@ private fun RelayGroupMembers( topBar = { TopBarExtensibleWithBackButton( title = { - Text( - text = stringRes(R.string.relay_group_members_title), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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 -> - LazyColumn(modifier = Modifier.padding(padding)) { - items(roster, key = { it.pubkey }) { entry -> - RelayGroupMemberRow( - entry = entry, - channel = channel, - isSelf = entry.pubkey == myPubkey, - viewerCanModerate = iCanModerate, - viewerIsAdmin = iAmAdmin, - accountViewModel = accountViewModel, - nav = nav, - ) - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + 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, + ) + } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 89b4b1797c..700854f53e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1967,6 +1967,8 @@ Edit group Threads Open group + No groups on this relay yet. + Preparing invite… No threads yet. Start one with the + button. New thread Untitled From 7e680ddea0fa2d75dbde24ece6916d52e2741d26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 19:06:18 +0000 Subject: [PATCH 33/81] feat(nip29): richer group card + threads/browse polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card glow-up (from the UI review): ElevatedCard with a subtle avatar ring, the name promoted to titleMedium, a tonal "Private"/"Invite-only" status pill, and a member-count chip with a people icon + primary-tinted chevron — so the inline card reads as a living community, not a link row. Still fixed-layout / no reflow. Threads: gate the compose FAB on membership (a non-member's kind-11 is rejected by the relay), make per-thread reply counts reactive via observeNoteReplyCount (so a new kind-1111 comment bumps the count live), and use leading dividers. Browse: show an inline error when the pasted relay URL doesn't normalize, and drop a redundant Row wrapper around the text field. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/components/RelayGroupCard.kt | 103 +++++++++++++----- .../relayGroup/RelayGroupBrowseScreen.kt | 40 ++++--- .../relayGroup/RelayGroupThreadsScreen.kt | 31 ++++-- amethyst/src/main/res/values/strings.xml | 3 + 4 files changed, 123 insertions(+), 54 deletions(-) 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 index b0c6a6db30..371939fece 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -20,6 +20,7 @@ */ 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 @@ -27,8 +28,11 @@ 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.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -100,27 +104,28 @@ private fun RelayGroupCardContent( val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() val memberCount = channel.memberCount() - - // Relay host is known immediately; the member count fills in when the roster loads. - // Both render on the same single line so nothing reflows as the count arrives. val relayLabel = channel.groupId.relayUrl.displayUrl() - val subtitle = - if (memberCount > 0) { - "$relayLabel · ${pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)}" - } else { - relayLabel + + // 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() } - OutlinedCard( + 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)) { @@ -133,7 +138,11 @@ private fun RelayGroupCardContent( robot = channel.groupId.id, model = channel.profilePicture(), contentDescription = channel.toBestDisplayName(), - modifier = Modifier.size(48.dp).clip(CircleShape), + 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, @@ -142,38 +151,58 @@ private fun RelayGroupCardContent( Column(Modifier.weight(1f)) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - if (channel.isPrivate()) { - Icon( - symbol = MaterialSymbols.Lock, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.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), + ) } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } Icon( symbol = MaterialSymbols.ChevronRight, contentDescription = stringRes(R.string.relay_group_open), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), ) } @@ -190,3 +219,19 @@ private fun RelayGroupCardContent( } } } + +/** 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/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt index 0c4d8707ba..2d87e85c63 100644 --- 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 @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup 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 @@ -77,12 +76,17 @@ fun RelayGroupBrowseScreen( nav: INav, ) { var relayUrl by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } val joined by accountViewModel.account.relayGroupList.liveRelayGroupServers .collectAsStateWithLifecycle() fun open(url: String) { - val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) ?: return + val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) + if (normalized == null) { + showError = true + return + } nav.nav(Route.RelayGroupServer(normalized.url)) } @@ -114,20 +118,26 @@ fun RelayGroupBrowseScreen( modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) - Row( + OutlinedTextField( + value = relayUrl, + onValueChange = { + relayUrl = it + showError = false + }, + 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), - ) { - OutlinedTextField( - value = relayUrl, - onValueChange = { relayUrl = it }, - singleLine = true, - 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.weight(1f), - ) - } + ) Button( onClick = { open(relayUrl) }, 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 index 419eaac887..a176698ffe 100644 --- 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 @@ -30,7 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -52,6 +52,7 @@ 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 @@ -98,6 +99,10 @@ private fun RelayGroupThreads( val threads by channel.threads.collectAsStateWithLifecycle() var showCompose by remember { mutableStateOf(false) } + // 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( @@ -122,12 +127,14 @@ private fun RelayGroupThreads( ) }, floatingActionButton = { - FloatingActionButton(onClick = { showCompose = true }) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = stringRes(R.string.relay_group_thread_new), - modifier = Modifier.size(24.dp), - ) + if (canPost) { + FloatingActionButton(onClick = { showCompose = true }) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_thread_new), + modifier = Modifier.size(24.dp), + ) + } } }, ) { padding -> @@ -142,9 +149,11 @@ private fun RelayGroupThreads( } } else { LazyColumn(modifier = Modifier.padding(padding)) { - items(threads, key = { it.idHex }) { thread -> + 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)) } - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -162,6 +171,9 @@ private fun ThreadRow( 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 = @@ -170,7 +182,6 @@ private fun ThreadRow( ?.replace('\n', ' ') ?.trim() .orEmpty() - val replyCount = thread.replies.size val author = thread.author Row( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 700854f53e..db1144d0e4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1969,6 +1969,9 @@ Open group No groups on this relay yet. Preparing invite… + Private + Invite-only + Enter a valid relay URL (wss://…). No threads yet. Start one with the + button. New thread Untitled From f3088d00c5ad86214f70f2a434d2f9d15e6caa6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 19:40:17 +0000 Subject: [PATCH 34/81] feat(nip29): convert group create/edit/new-thread dialogs to full screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content-authoring flows are screens in Amethyst, not dialogs. Promote the three NIP-29 group dialogs to routed screens: - RelayGroupCreateScreen / RelayGroupEditScreen — a shared metadata form backed by RelayGroupMetadataViewModel with a tap-to-upload avatar hero (gallery pick → compress → NIP-96/Blossom upload, mirroring the emoji-pack editor). Create now exposes EVERY group parameter: name, description, picture, and all four status flags (private, invite-only, members-only posting, unlisted), each with a one-line explanation. Edit prefills reactively from the metadata flow and won't clobber in-progress edits. - RelayGroupNewThreadScreen — full-screen title+body composer with rememberSaveable state so a half-written thread survives rotation / process death. Plumbing: Account + AccountViewModel createRelayGroup/editRelayGroupMetadata gain picture + isHidden + isRestricted (the quartz 9002 builder already supported them). Routes RelayGroupNewThread/Create/Edit registered; the Threads FAB, channel-list FAB, and topbar Edit menu now navigate to them; the three dialog files are removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 56 ++- .../amethyst/ui/navigation/AppNavigation.kt | 29 ++ .../amethyst/ui/navigation/routes/Routes.kt | 14 + .../ui/screen/loggedIn/AccountViewModel.kt | 14 +- .../relayGroup/CreateRelayGroupDialog.kt | 130 ------- .../relayGroup/EditRelayGroupDialog.kt | 118 ------ .../relayGroup/NewRelayGroupThreadDialog.kt | 91 ----- .../relayGroup/RelayGroupChannelListScreen.kt | 11 +- .../relayGroup/RelayGroupMetadataScreen.kt | 357 ++++++++++++++++++ .../relayGroup/RelayGroupMetadataViewModel.kt | 286 ++++++++++++++ .../relayGroup/RelayGroupNewThreadScreen.kt | 129 +++++++ .../relayGroup/RelayGroupThreadsScreen.kt | 11 +- .../relayGroup/RelayGroupTopBar.kt | 7 +- amethyst/src/main/res/values/strings.xml | 14 + 14 files changed, 887 insertions(+), 380 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt 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 6e9c168b03..147cd046fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1498,19 +1498,22 @@ class Account( groupId: String, name: String, about: String? = null, + picture: String? = null, isPrivate: Boolean = false, isClosed: Boolean = false, + isHidden: Boolean = false, + isRestricted: Boolean = false, ): GroupId { signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } - val status = - buildSet { - // NIP-29 flags are presence-only: public/open is the absence of the - // private/closed tags, so emit only the restrictive flags that are on. - if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) - if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) - } - val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status) + val edit = + EditMetadataEvent.build( + groupId, + name = name, + about = about, + picture = picture, + status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + ) signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } val id = GroupId(groupId, relay) @@ -1518,6 +1521,24 @@ class Account( 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, @@ -1564,17 +1585,20 @@ class Account( channel: RelayGroupChannel, name: String?, about: String?, + picture: String?, isPrivate: Boolean, isClosed: Boolean, + isHidden: Boolean, + isRestricted: Boolean, ) { - val status = - buildSet { - // NIP-29 flags are presence-only: public/open is the absence of the - // private/closed tags, so emit only the restrictive flags that are on. - if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) - if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) - } - val template = EditMetadataEvent.build(channel.groupId.id, name = name, about = about, status = status) + val template = + EditMetadataEvent.build( + channel.groupId.id, + name = name, + about = about, + picture = picture, + status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } 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 ae9b329d87..ff875d204c 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 @@ -108,7 +108,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53L 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.RelayGroupEditScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupNewThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupsHomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen @@ -600,6 +603,32 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupNewThreadScreen( + 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, 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 9bab7e56b8..b6301cffc7 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 @@ -661,6 +661,20 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable data class RelayGroupNewThread( + 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() 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 9724dc6313..8f7dc801b9 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 @@ -1443,9 +1443,14 @@ class AccountViewModel( groupId: String, name: String, about: String?, + picture: String?, isPrivate: Boolean, isClosed: Boolean, - ) = launchSigner { account.createRelayGroup(relay, groupId, name, about, isPrivate, isClosed) } + isHidden: Boolean, + isRestricted: Boolean, + ) = launchSigner { + account.createRelayGroup(relay, groupId, name, about, picture, isPrivate, isClosed, isHidden, isRestricted) + } fun createRelayGroupInvite( channel: RelayGroupChannel, @@ -1473,9 +1478,14 @@ class AccountViewModel( channel: RelayGroupChannel, name: String?, about: String?, + picture: String?, isPrivate: Boolean, isClosed: Boolean, - ) = launchSigner { account.editRelayGroupMetadata(channel, name, about, isPrivate, isClosed) } + isHidden: Boolean, + isRestricted: Boolean, + ) = launchSigner { + account.editRelayGroupMetadata(channel, name, about, picture, isPrivate, isClosed, isHidden, isRestricted) + } fun follow(users: List) = launchSigner { account.follow(users) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt deleted file mode 100644 index 71a6038c08..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CreateRelayGroupDialog.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * 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.Row -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.Switch -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.unit.dp -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.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.utils.RandomInstance - -/** Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada). */ -private fun randomGroupId(): String = RandomInstance.bytes(8).toHexKey() - -/** - * Create a NIP-29 group on [relay]: publishes kind 9007 + 9002 with the chosen - * name/visibility, then navigates into the new group. The user becomes its first - * admin. - */ -@Composable -fun CreateRelayGroupDialog( - relay: NormalizedRelayUrl, - accountViewModel: AccountViewModel, - nav: INav, - onDismiss: () -> Unit, -) { - var name by remember { mutableStateOf("") } - var about by remember { mutableStateOf("") } - var isPrivate by remember { mutableStateOf(false) } - var isClosed by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.relay_group_create_title)) }, - text = { - Column { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_create_name)) }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = about, - onValueChange = { about = it }, - label = { Text(stringRes(R.string.relay_group_create_topic)) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - ToggleRow(stringRes(R.string.relay_group_create_private), isPrivate) { isPrivate = it } - ToggleRow(stringRes(R.string.relay_group_create_invite_only), isClosed) { isClosed = it } - } - }, - confirmButton = { - TextButton( - enabled = name.isNotBlank(), - onClick = { - val groupId = randomGroupId() - accountViewModel.createRelayGroup( - relay = relay, - groupId = groupId, - name = name.trim(), - about = about.trim().ifBlank { null }, - isPrivate = isPrivate, - isClosed = isClosed, - ) - onDismiss() - nav.nav(Route.RelayGroup(groupId, relay.url)) - }, - ) { - Text(stringRes(R.string.relay_group_create_confirm)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } - }, - ) -} - -@Composable -private fun ToggleRow( - label: String, - checked: Boolean, - onChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, modifier = Modifier.weight(1f)) - Switch(checked = checked, onCheckedChange = onChange) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt deleted file mode 100644 index f658198990..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/EditRelayGroupDialog.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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.Row -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.Switch -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.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 - -/** - * Edit a NIP-29 group's relay-signed metadata (kind 9002). Pre-filled from the - * channel's current metadata; the host relay only honours it from an admin. - */ -@Composable -fun EditRelayGroupDialog( - channel: RelayGroupChannel, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, -) { - var name by remember(channel.groupId) { mutableStateOf(channel.event?.name() ?: "") } - var about by remember(channel.groupId) { mutableStateOf(channel.event?.about() ?: "") } - var isPrivate by remember(channel.groupId) { mutableStateOf(channel.isPrivate()) } - var isClosed by remember(channel.groupId) { mutableStateOf(channel.isClosed()) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.relay_group_edit_title)) }, - text = { - Column { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_edit_name)) }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = about, - onValueChange = { about = it }, - label = { Text(stringRes(R.string.relay_group_edit_topic)) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - EditToggleRow(stringRes(R.string.relay_group_edit_private), isPrivate) { isPrivate = it } - EditToggleRow(stringRes(R.string.relay_group_edit_invite_only), isClosed) { isClosed = it } - } - }, - confirmButton = { - TextButton( - enabled = name.isNotBlank(), - onClick = { - accountViewModel.editRelayGroupMetadata( - channel = channel, - name = name.trim(), - about = about.trim().ifBlank { null }, - isPrivate = isPrivate, - isClosed = isClosed, - ) - onDismiss() - }, - ) { - Text(stringRes(R.string.relay_group_edit_confirm)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } - }, - ) -} - -@Composable -private fun EditToggleRow( - label: String, - checked: Boolean, - onChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, modifier = Modifier.weight(1f)) - Switch(checked = checked, onCheckedChange = onChange) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt deleted file mode 100644 index 5718b36abd..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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.heightIn -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 - -/** - * Start a new kind-11 thread in the group: a required title plus a body. Publishes - * to the group's host relay (the relay accepts it only from a member). - */ -@Composable -fun NewRelayGroupThreadDialog( - channel: RelayGroupChannel, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, -) { - var title by remember { mutableStateOf("") } - var body by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.relay_group_thread_new)) }, - text = { - Column { - OutlinedTextField( - value = title, - onValueChange = { title = it }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_thread_title_label)) }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = body, - onValueChange = { body = it }, - label = { Text(stringRes(R.string.relay_group_thread_body_label)) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp).padding(top = 8.dp), - ) - } - }, - confirmButton = { - TextButton( - enabled = title.isNotBlank() && body.isNotBlank(), - onClick = { - accountViewModel.postRelayGroupThread(channel, title.trim(), body.trim()) - onDismiss() - }, - ) { - Text(stringRes(R.string.relay_group_thread_post)) - } - }, - 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/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 4d0a7acef8..feeb2d25b8 100644 --- 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 @@ -39,10 +39,8 @@ 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.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 @@ -58,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.model.LocalCache 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 @@ -82,8 +81,6 @@ fun RelayGroupChannelListScreen( ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return - var showCreate by remember { mutableStateOf(false) } - RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) // Re-read the relay's channels whenever a group-metadata (kind 39000) event lands in @@ -116,7 +113,7 @@ fun RelayGroupChannelListScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { showCreate = true }) { + FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }) { Icon( symbol = MaterialSymbols.Add, contentDescription = stringRes(R.string.relay_group_create_title), @@ -146,10 +143,6 @@ fun RelayGroupChannelListScreen( } } } - - if (showCreate) { - CreateRelayGroupDialog(relay, accountViewModel, nav) { showCreate = false } - } } @Composable 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..eead0bddb6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -0,0 +1,357 @@ +/* + * 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.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.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.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.RelayGroupPreviewSubscription +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) } + + RelayGroupMetadataScaffold( + viewModel = viewModel, + accountViewModel = accountViewModel, + nav = nav, + 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. + RelayGroupPreviewSubscription(channel, accountViewModel.dataSources().relayGroupPreview, 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, +) { + 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, + 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), + ) { + GroupImagePicker(viewModel) { wantsToPickImage = true } + + Spacer(Modifier.height(16.dp)) + + GroupMetadataFields(viewModel) + } + } + } +} + +/** 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_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..ab4fdf36bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -0,0 +1,286 @@ +/* + * 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()) + + 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 + } + + 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 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, + ) + } else { + account.editRelayGroupMetadata( + channel = existing, + name = name, + about = about, + picture = picture, + isPrivate = isPrivate, + isClosed = isClosed, + isHidden = isHidden, + isRestricted = isRestricted, + ) + } + } + + 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/RelayGroupNewThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt new file mode 100644 index 0000000000..34443cf40b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * Full-screen composer for a new kind-11 group thread (a title plus a body). Replaces + * the old cramped dialog: state is [rememberSaveable] so a half-written thread survives + * rotation / process death, and the group's host relay accepts it only from a member. + */ +@Composable +fun RelayGroupNewThreadScreen( + id: HexKey, + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + val groupId = remember(id, relay) { GroupId(id, relay) } + + var title by rememberSaveable { mutableStateOf("") } + var body by rememberSaveable { mutableStateOf("") } + + LoadRelayGroupChannel(groupId, accountViewModel) { channel -> + Scaffold( + topBar = { + TopBarExtensibleWithBackButton( + title = { + Column { + Text( + text = stringRes(R.string.relay_group_thread_new), + 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, + ) + } + }, + actions = { + TextButton( + enabled = title.isNotBlank() && body.isNotBlank(), + onClick = { + accountViewModel.postRelayGroupThread(channel, title.trim(), body.trim()) + nav.popBack() + }, + ) { + Text(stringRes(R.string.relay_group_thread_post)) + } + }, + popBack = nav::popBack, + ) + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_thread_title_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text(stringRes(R.string.relay_group_thread_body_label)) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp).padding(top = 8.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 index a176698ffe..756629d8ed 100644 --- 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 @@ -38,9 +38,7 @@ 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -97,7 +95,6 @@ private fun RelayGroupThreads( RelayGroupThreadsSubscription(channel, accountViewModel.dataSources().relayGroupThreads, accountViewModel) val threads by channel.threads.collectAsStateWithLifecycle() - var showCompose by remember { mutableStateOf(false) } // Only members can post a thread (the relay rejects a non-member's kind-11), so the // compose FAB is hidden for everyone else. @@ -128,7 +125,9 @@ private fun RelayGroupThreads( }, floatingActionButton = { if (canPost) { - FloatingActionButton(onClick = { showCompose = true }) { + FloatingActionButton( + onClick = { nav.nav(Route.RelayGroupNewThread(channel.groupId.id, channel.groupId.relayUrl.url)) }, + ) { Icon( symbol = MaterialSymbols.Add, contentDescription = stringRes(R.string.relay_group_thread_new), @@ -158,10 +157,6 @@ private fun RelayGroupThreads( } } } - - if (showCompose) { - NewRelayGroupThreadDialog(channel, accountViewModel) { showCompose = false } - } } @Composable 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 index ce63adbb11..186be5f1f5 100644 --- 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 @@ -100,7 +100,6 @@ fun RelayGroupTopBar( var menuOpen by remember { mutableStateOf(false) } var showInvite by remember { mutableStateOf(false) } var showJoinCode by remember { mutableStateOf(false) } - var showEdit by remember { mutableStateOf(false) } TopBarExtensibleWithBackButton( title = { @@ -220,7 +219,7 @@ fun RelayGroupTopBar( text = { Text(stringRes(R.string.relay_group_menu_edit)) }, onClick = { menuOpen = false - showEdit = true + nav.nav(Route.RelayGroupEdit(channel.groupId.id, channel.groupId.relayUrl.url)) }, ) } @@ -251,10 +250,6 @@ fun RelayGroupTopBar( InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false } } - if (showEdit) { - EditRelayGroupDialog(channel, accountViewModel) { showEdit = false } - } - if (showJoinCode) { JoinRelayGroupDialog( channel = channel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index db1144d0e4..236084bcea 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1972,6 +1972,20 @@ Private Invite-only Enter a valid relay URL (wss://…). + Name + Description + Group picture + Add photo + Change photo + 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. No threads yet. Start one with the + button. New thread Untitled From a18cad6121cadc7e8dee3950b1ebc73db9857935 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 20:21:41 +0000 Subject: [PATCH 35/81] feat(nip29): relay-group discovery feed with top-bar filter + favorites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A discovery feed to find and join groups, modeled on the Git/Pictures top-nav filter. Because a group's kind-39000 is relay-signed (not author/tag scoped), every filter option reduces to a RELAY SET: the feed queries kind 39000 on the relays the selected filter resolves to. - Global / Follows / Around me — resolved via the account's shared topNavFilterFlow + outbox loader (relayKeys() takes the per-relay set's keys). Driven by a ViewModel-local TopFilter, so no account-settings/LocalPreferences plumbing and no persistence (resets to Global per visit). - Favorite relays (B) — a toggle backed by the kind-10012 relay-feeds list (account.relayFeedsList); a star on each card adds/removes the group's host relay via followRelayFeed/unfollowRelayFeed. - Fans the existing per-relay RelayGroupDirectorySubscription out across the set (capped at 40 relays); the feed reads LocalCache.relayGroupChannels for those relays, re-scanning as kind-39000 events arrive. - Joinable cards: avatar, name, member count, private/invite-only pill, Join (open groups join directly; closed ones open the group for the code), and the favorite-relay star. "Find groups" now opens discovery; the paste-a-relay browse is a top-bar action. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/navigation/AppNavigation.kt | 8 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../relayGroup/RelayGroupDiscoveryScreen.kt | 346 ++++++++++++++++++ .../RelayGroupDiscoveryViewModel.kt | 134 +++++++ .../relayGroup/RelayGroupServerList.kt | 2 +- amethyst/src/main/res/values/strings.xml | 8 + 6 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt 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 ff875d204c..3b13dcc4ef 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 @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG 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.RelayGroupNewThreadScreen @@ -636,6 +637,13 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupDiscoveryScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } 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 b6301cffc7..9903c88929 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 @@ -679,6 +679,8 @@ sealed class Route { @Serializable object RelayGroupBrowse : Route() + @Serializable object RelayGroupDiscovery : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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..302c09cec2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -0,0 +1,346 @@ +/* + * 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.items +import androidx.compose.foundation.shape.CircleShape +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.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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.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 androidx.lifecycle.viewmodel.compose.viewModel +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.TopFilter +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.RelayGroupDirectorySubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +private class DiscoveryFilterOption( + val labelRes: Int, + val isSelected: (RelayGroupDiscoveryViewModel) -> Boolean, + val apply: (RelayGroupDiscoveryViewModel) -> Unit, +) + +private val DISCOVERY_FILTERS = + listOf( + DiscoveryFilterOption(R.string.relay_group_discovery_filter_global, { !it.favorites.value && it.filter.value == TopFilter.Global }) { + it.favorites.value = false + it.filter.value = TopFilter.Global + }, + DiscoveryFilterOption(R.string.relay_group_discovery_filter_follows, { !it.favorites.value && it.filter.value == TopFilter.AllFollows }) { + it.favorites.value = false + it.filter.value = TopFilter.AllFollows + }, + DiscoveryFilterOption(R.string.relay_group_discovery_filter_around_me, { !it.favorites.value && it.filter.value == TopFilter.AroundMe }) { + it.favorites.value = false + it.filter.value = TopFilter.AroundMe + }, + DiscoveryFilterOption(R.string.relay_group_discovery_filter_favorites, { it.favorites.value }) { + it.favorites.value = true + }, + ) + +/** How many relays we fan the directory query out to at once, so "Global" stays bounded. */ +private const val MAX_DISCOVERY_RELAYS = 40 + +/** + * Discover NIP-29 groups across a relay set chosen by a top-bar filter (Global / Follows / + * Around Me / Favorites — see [RelayGroupDiscoveryViewModel]). Fans the group-directory + * query out to those relays and lists every group they host as a joinable card. + */ +@Composable +fun RelayGroupDiscoveryScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: RelayGroupDiscoveryViewModel = viewModel() + viewModel.init(accountViewModel.account) + + val relays by viewModel.relays.collectAsStateWithLifecycle() + val groups by viewModel.groups.collectAsStateWithLifecycle() + val favorites by viewModel.favorites.collectAsStateWithLifecycle() + val filter by viewModel.filter.collectAsStateWithLifecycle() + val favoriteRelays by accountViewModel.account.relayFeedsList.flow + .collectAsStateWithLifecycle() + + // Fan the directory subscription out to each relay in the current set while the screen + // is visible; each is a lifecycle-aware per-relay REQ that EOSEs and dedupes by relay. + relays.take(MAX_DISCOVERY_RELAYS).forEach { relay -> + key(relay) { + RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) + } + } + + var menuOpen by remember { mutableStateOf(false) } + val currentLabel = stringRes(DISCOVERY_FILTERS.first { it.isSelected(viewModel) }.labelRes) + + Scaffold( + topBar = { + TopBarExtensibleWithBackButton( + title = { + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { menuOpen = true } + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Column { + Text( + text = stringRes(R.string.relay_group_discovery_title), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = currentLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } + Icon( + symbol = MaterialSymbols.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DISCOVERY_FILTERS.forEach { option -> + DropdownMenuItem( + text = { Text(stringRes(option.labelRes)) }, + onClick = { + option.apply(viewModel) + menuOpen = false + }, + ) + } + } + } + }, + actions = { + IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) { + Icon( + symbol = MaterialSymbols.Link, + contentDescription = stringRes(R.string.relay_group_browse_title), + modifier = Modifier.size(22.dp), + ) + } + }, + popBack = nav::popBack, + ) + }, + ) { padding -> + if (groups.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + text = + if (favorites || filter != TopFilter.Global) { + stringRes(R.string.relay_group_discovery_empty_filtered) + } else { + stringRes(R.string.relay_group_discovery_empty) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(32.dp), + ) + } + } else { + val myPubkey = accountViewModel.userProfile().pubkeyHex + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(groups, key = { it.groupId.toKey() }) { channel -> + RelayGroupDiscoveryCard( + channel = channel, + myPubkey = myPubkey, + isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +@Composable +private fun RelayGroupDiscoveryCard( + channel: RelayGroupChannel, + myPubkey: String, + isFavoriteRelay: Boolean, + 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 + + Row( + modifier = + Modifier + .fillMaxWidth() + .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(48.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + + Column( + Modifier + .weight(1f) + .clickable { nav.nav(routeFor(channel)) }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = channel.toBestDisplayName(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + DiscoveryStatusPill(channel) + } + val subtitle = + if (memberCount > 0) { + "${relay.displayUrl()} · ${pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)}" + } else { + relay.displayUrl() + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + channel.summary()?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Star the group's host relay so it shows under the Favorites filter. + IconButton(onClick = { + if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay) + }) { + Icon( + symbol = if (isFavoriteRelay) MaterialSymbols.Star else MaterialSymbols.StarBorder, + contentDescription = stringRes(R.string.relay_group_favorite_relay), + tint = if (isFavoriteRelay) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + + 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)) + } + } + } +} + +/** 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/RelayGroupDiscoveryViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt new file mode 100644 index 0000000000..e8bdb49331 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt @@ -0,0 +1,134 @@ +/* + * 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.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.TopFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState +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.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * The relays a top-nav filter resolves to. A NIP-29 group's kind-39000 is relay-signed + * (not authored by follows, not hashtag/geo-tagged), so — unlike the author-based feeds — + * the ONLY thing a filter contributes to group discovery is its RELAY SET. We take the + * keys of whatever per-relay set the selected [TopFilter] produced and query kind 39000 + * on them. + */ +fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = + when (this) { + is GlobalTopNavPerRelayFilterSet -> set.keys + is AllFollowsTopNavPerRelayFilterSet -> set.keys + is AuthorsTopNavPerRelayFilterSet -> set.keys + is HashtagTopNavPerRelayFilterSet -> set.keys + is LocationTopNavPerRelayFilterSet -> set.keys + is AllCommunitiesTopNavPerRelayFilterSet -> set.keys + is SingleCommunityTopNavPerRelayFilterSet -> set.keys + is MutedAuthorsTopNavPerRelayFilterSet -> set.keys + else -> emptySet() + } + +/** + * Drives the Relay Groups discovery feed. A top-bar filter ([Global], [AllFollows], + * [AroundMe], …) selects a relay set via the account's shared `topNavFilterFlow` + + * outbox resolution; a [favorites] toggle swaps in the user's kind-10009… kind-10012 + * favorite-relays list instead. Either way the feed is every group (kind 39000) those + * relays host, read from [LocalCache] as directory events arrive. Selection is kept in + * this ViewModel (not persisted) — a discovery screen resets to Global each visit. + */ +@Stable +class RelayGroupDiscoveryViewModel : ViewModel() { + private lateinit var account: Account + + val filter = MutableStateFlow(TopFilter.Global) + val favorites = MutableStateFlow(false) + + lateinit var relays: StateFlow> + private set + + lateinit var groups: StateFlow> + private set + + @OptIn(ExperimentalCoroutinesApi::class) + fun init(acc: Account) { + if (this::account.isInitialized) return + account = acc + + val perRelaySet = OutboxLoaderState(account.topNavFilterFlow(filter), LocalCache, viewModelScope).flow + + relays = + combine(favorites, perRelaySet, account.relayFeedsList.flow) { showFavorites, perRelay, favoriteRelays -> + if (showFavorites) favoriteRelays else perRelay.relayKeys() + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) + + groups = + relays + .flatMapLatest { relaySet -> + // Re-scan the cache whenever a new kind-39000 lands; the initial emit renders + // whatever's already cached for this relay set immediately. + LocalCache + .observeEvents(Filter(kinds = listOf(GroupMetadataEvent.KIND))) + .onStart { emit(emptyList()) } + .map { groupsOnRelays(relaySet) } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + } + + private fun groupsOnRelays(relaySet: Set): List = + if (relaySet.isEmpty()) { + emptyList() + } else { + LocalCache.relayGroupChannels + .filter { key, channel -> key.relayUrl in relaySet && channel.event != null } + .sortedWith( + compareByDescending { it.memberCount() } + .thenBy { it.toBestDisplayName().lowercase() }, + ) + } + + fun isFavorite(relay: NormalizedRelayUrl): Boolean = relay in account.relayFeedsList.flow.value +} 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 index 4e49307b8b..6670409fc3 100644 --- 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 @@ -72,7 +72,7 @@ fun RelayGroupServerList( // Always offer a discovery entry, so a user with no groups yet still has // somewhere to start. - FindChannelsRow { nav.nav(Route.RelayGroupBrowse) } + FindChannelsRow { nav.nav(Route.RelayGroupDiscovery) } HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 236084bcea..251247a909 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1986,6 +1986,14 @@ Only members can post messages. Unlisted Hide this group from the relay\'s public directory. + Discover groups + Global + Follows + Around me + Favorite relays + 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 From 1b1413f861615c50aacf957112ea50ce2aad9e12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 20:50:01 +0000 Subject: [PATCH 36/81] refactor(nip29): drive group discovery through the standard feed-filter pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the Pictures/Git pattern exactly instead of the custom lean variant: - Persist the selection in a new `defaultRelayGroupsDiscoveryFollowList` account setting (AccountSettings + LocalPreferences save/parse/wire + change mutator + Account `liveRelayGroupsDiscoveryFollowLists(PerRelay)` flow) — like every other feed. A missing pref key just defaults to Global, so it's additive, not a migration. - Top bar uses the shared `FeedFilterSpinner` bound to that setting, so all the standard options apply — Global, Follows, followed hashtags/geohashes, and per-relay chips (a starred favorite relay shows up as a chip). No bespoke filter enum, no separate favorites toggle. - Remove the 40-relay cap: fan the directory query out to every relay in the set, matching the other global feeds. - ViewModel now reads `liveRelayGroupsDiscoveryFollowListsPerRelay` → relayKeys() (extended for the Relay chip) and lists the groups those relays host. The ★ still stars a group's host relay (kind-10012 relay-feeds list), which is what surfaces it as a relay chip in the filter. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountSettings.kt | 12 ++ .../relayGroup/RelayGroupDiscoveryScreen.kt | 112 ++++-------------- .../RelayGroupDiscoveryViewModel.kt | 33 ++---- amethyst/src/main/res/values/strings.xml | 5 - 6 files changed, 56 insertions(+), 114 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 32eb9c41f2..71f73471b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -108,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" @@ -430,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)) @@ -806,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), @@ -902,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, @@ -958,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.Global), 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 147cd046fe..c8e3f08fc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -556,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 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 b59f2ed26b..6ec2e769a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -215,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.Global), 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 @@ -658,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) } 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 index 302c09cec2..1964e0ad49 100644 --- 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 @@ -33,8 +33,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape 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.HorizontalDivider import androidx.compose.material3.IconButton @@ -45,9 +43,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key -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.draw.clip @@ -66,44 +61,19 @@ 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.FeedFilterSpinner 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.RelayGroupDirectorySubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -private class DiscoveryFilterOption( - val labelRes: Int, - val isSelected: (RelayGroupDiscoveryViewModel) -> Boolean, - val apply: (RelayGroupDiscoveryViewModel) -> Unit, -) - -private val DISCOVERY_FILTERS = - listOf( - DiscoveryFilterOption(R.string.relay_group_discovery_filter_global, { !it.favorites.value && it.filter.value == TopFilter.Global }) { - it.favorites.value = false - it.filter.value = TopFilter.Global - }, - DiscoveryFilterOption(R.string.relay_group_discovery_filter_follows, { !it.favorites.value && it.filter.value == TopFilter.AllFollows }) { - it.favorites.value = false - it.filter.value = TopFilter.AllFollows - }, - DiscoveryFilterOption(R.string.relay_group_discovery_filter_around_me, { !it.favorites.value && it.filter.value == TopFilter.AroundMe }) { - it.favorites.value = false - it.filter.value = TopFilter.AroundMe - }, - DiscoveryFilterOption(R.string.relay_group_discovery_filter_favorites, { it.favorites.value }) { - it.favorites.value = true - }, - ) - -/** How many relays we fan the directory query out to at once, so "Global" stays bounded. */ -private const val MAX_DISCOVERY_RELAYS = 40 - /** - * Discover NIP-29 groups across a relay set chosen by a top-bar filter (Global / Follows / - * Around Me / Favorites — see [RelayGroupDiscoveryViewModel]). Fans the group-directory - * query out to those relays and lists every group they host as a joinable card. + * Discover NIP-29 groups across the relay set the top-bar filter resolves to. Mirrors the + * Git/Pictures feeds: a [FeedFilterSpinner] persists the selection to + * `defaultRelayGroupsDiscoveryFollowList` (Global / Follows / a followed hashtag or geohash / + * a specific relay — favorite relays show up as relay chips). The group directory query is + * fanned out to every relay in that set and each group is listed as a joinable card. */ @Composable fun RelayGroupDiscoveryScreen( @@ -115,69 +85,32 @@ fun RelayGroupDiscoveryScreen( val relays by viewModel.relays.collectAsStateWithLifecycle() val groups by viewModel.groups.collectAsStateWithLifecycle() - val favorites by viewModel.favorites.collectAsStateWithLifecycle() - val filter by viewModel.filter.collectAsStateWithLifecycle() + val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList + .collectAsStateWithLifecycle() val favoriteRelays by accountViewModel.account.relayFeedsList.flow .collectAsStateWithLifecycle() // Fan the directory subscription out to each relay in the current set while the screen // is visible; each is a lifecycle-aware per-relay REQ that EOSEs and dedupes by relay. - relays.take(MAX_DISCOVERY_RELAYS).forEach { relay -> + relays.forEach { relay -> key(relay) { RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) } } - var menuOpen by remember { mutableStateOf(false) } - val currentLabel = stringRes(DISCOVERY_FILTERS.first { it.isSelected(viewModel) }.labelRes) - Scaffold( topBar = { + val options by accountViewModel.feedStates.feedListOptions.kind3GlobalPeopleRoutes + .collectAsStateWithLifecycle() TopBarExtensibleWithBackButton( title = { - Box { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .clip(RoundedCornerShape(6.dp)) - .clickable { menuOpen = true } - .padding(horizontal = 4.dp, vertical = 2.dp), - ) { - Column { - Text( - text = stringRes(R.string.relay_group_discovery_title), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = currentLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - ) - } - Icon( - symbol = MaterialSymbols.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DISCOVERY_FILTERS.forEach { option -> - DropdownMenuItem( - text = { Text(stringRes(option.labelRes)) }, - onClick = { - option.apply(viewModel) - menuOpen = false - }, - ) - } - } - } + FeedFilterSpinner( + placeholderCode = selectedFilter, + explainer = stringRes(R.string.select_list_to_filter), + options = options, + onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList, + accountViewModel = accountViewModel, + ) }, actions = { IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) { @@ -196,10 +129,10 @@ fun RelayGroupDiscoveryScreen( Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( text = - if (favorites || filter != TopFilter.Global) { - stringRes(R.string.relay_group_discovery_empty_filtered) - } else { + if (selectedFilter == TopFilter.Global) { stringRes(R.string.relay_group_discovery_empty) + } else { + stringRes(R.string.relay_group_discovery_empty_filtered) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -298,7 +231,8 @@ private fun RelayGroupDiscoveryCard( } } - // Star the group's host relay so it shows under the Favorites filter. + // Star the group's host relay so it (and its other groups) surface under the relay + // chip in the filter — the favorite is the kind-10012 relay-feeds list. IconButton(onClick = { if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay) }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt index e8bdb49331..9e672f1333 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt @@ -26,9 +26,7 @@ import androidx.lifecycle.viewModelScope 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.TopFilter import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet @@ -37,15 +35,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.All 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.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -69,24 +66,24 @@ fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = is AllCommunitiesTopNavPerRelayFilterSet -> set.keys is SingleCommunityTopNavPerRelayFilterSet -> set.keys is MutedAuthorsTopNavPerRelayFilterSet -> set.keys + // A single relay chip (includes a starred favorite relay). + is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) + // DVM algo-feed selections carry no group-hosting relays. else -> emptySet() } /** - * Drives the Relay Groups discovery feed. A top-bar filter ([Global], [AllFollows], - * [AroundMe], …) selects a relay set via the account's shared `topNavFilterFlow` + - * outbox resolution; a [favorites] toggle swaps in the user's kind-10009… kind-10012 - * favorite-relays list instead. Either way the feed is every group (kind 39000) those - * relays host, read from [LocalCache] as directory events arrive. Selection is kept in - * this ViewModel (not persisted) — a discovery screen resets to Global each visit. + * Drives the Relay Groups discovery feed. The top bar's [FeedFilterSpinner] writes the + * selection to the account's persisted `defaultRelayGroupsDiscoveryFollowList` (Global / + * Follows / a followed hashtag or geohash / a specific relay — including favorite relays, + * which surface as relay chips), exactly like every other feed. Because a group's kind-39000 + * is relay-signed, the only thing the filter contributes is its RELAY SET ([relayKeys]); the + * feed is every group those relays host, read from [LocalCache] as directory events arrive. */ @Stable class RelayGroupDiscoveryViewModel : ViewModel() { private lateinit var account: Account - val filter = MutableStateFlow(TopFilter.Global) - val favorites = MutableStateFlow(false) - lateinit var relays: StateFlow> private set @@ -98,12 +95,10 @@ class RelayGroupDiscoveryViewModel : ViewModel() { if (this::account.isInitialized) return account = acc - val perRelaySet = OutboxLoaderState(account.topNavFilterFlow(filter), LocalCache, viewModelScope).flow - relays = - combine(favorites, perRelaySet, account.relayFeedsList.flow) { showFavorites, perRelay, favoriteRelays -> - if (showFavorites) favoriteRelays else perRelay.relayKeys() - }.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) + account.liveRelayGroupsDiscoveryFollowListsPerRelay + .map { it.relayKeys() } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) groups = relays @@ -129,6 +124,4 @@ class RelayGroupDiscoveryViewModel : ViewModel() { .thenBy { it.toBestDisplayName().lowercase() }, ) } - - fun isFavorite(relay: NormalizedRelayUrl): Boolean = relay in account.relayFeedsList.flow.value } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 251247a909..2f85dd4332 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1986,11 +1986,6 @@ Only members can post messages. Unlisted Hide this group from the relay\'s public directory. - Discover groups - Global - Follows - Around me - Favorite relays Looking for groups across your relays… No groups found for this filter yet. Favorite this relay From 0140b837b1161fe7fa4584e4a3821531bc78f2eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 21:42:38 +0000 Subject: [PATCH 37/81] feat: per-type relay group discovery (follows/admins/members + topics/geo) Relay-signed kind-39000 has no author-of-a-follow, but the people dimension still exists: a follow may be the relay signing key, a group admin (39001), or a member (39002). Discovery now resolves each top-nav filter into a per-relay GroupDiscoveryConstraint instead of collapsing every filter to the same REQ. quartz: - GroupMetadataEvent / EditMetadataEvent: build + read #t (topics) and #g (geohash, mip-mapped so a coarser followed geohash still matches). Interop tests for parse/build round-trips. amethyst: - dal/RelayGroupDiscoveryFeedFilter: sealed GroupDiscoveryConstraint (AllGroups / ByPeople / ByHashtags / ByGeohashes / AnyOf) + toGroupConstraints() mapping each IFeedTopNavPerRelayFilterSet to per-relay constraints, with matches() covering the relay-key/admin/member people paths and topic/geo tags. Unit tests. - Directory REQ narrows to 39000 #t/#g for topic/geo filters, broad directory otherwise (people match needs the rosters). - ViewModel keys the feed on the constraint map and re-scans on any directory event (metadata OR roster) so late-arriving admins/members surface groups. - Create/edit form gains a Discovery section (topics + geohash) threaded through Account.createRelayGroup/editRelayGroupMetadata and EditMetadata. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 8 + .../ui/screen/loggedIn/AccountViewModel.kt | 31 +++- .../relayGroup/RelayGroupDiscoveryScreen.kt | 16 +- .../RelayGroupDiscoveryViewModel.kt | 96 +++++------ .../relayGroup/RelayGroupMetadataScreen.kt | 36 ++++ .../relayGroup/RelayGroupMetadataViewModel.kt | 33 ++++ .../dal/RelayGroupDiscoveryFeedFilter.kt | 152 ++++++++++++++++ .../RelayGroupDirectoryFilterAssembler.kt | 47 +++-- .../RelayGroupDirectorySubscription.kt | 6 +- amethyst/src/main/res/values/strings.xml | 6 + .../dal/RelayGroupDiscoveryConstraintTest.kt | 163 ++++++++++++++++++ .../metadata/GroupMetadataEvent.kt | 22 +++ .../moderation/EditMetadataEvent.kt | 13 ++ .../Nip29ArmadaInteropTest.kt | 52 ++++++ 14 files changed, 608 insertions(+), 73 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt 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 c8e3f08fc8..b8f6eb22d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1506,6 +1506,8 @@ class Account( isClosed: Boolean = false, isHidden: Boolean = false, isRestricted: Boolean = false, + hashtags: List = emptyList(), + geohashes: List = emptyList(), ): GroupId { signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } @@ -1516,6 +1518,8 @@ class Account( about = about, picture = picture, status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + hashtags = hashtags, + geohashes = geohashes, ) signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } @@ -1593,6 +1597,8 @@ class Account( isClosed: Boolean, isHidden: Boolean, isRestricted: Boolean, + hashtags: List = emptyList(), + geohashes: List = emptyList(), ) { val template = EditMetadataEvent.build( @@ -1601,6 +1607,8 @@ class Account( about = about, picture = picture, status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + hashtags = hashtags, + geohashes = geohashes, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } 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 8f7dc801b9..a508365c5d 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 @@ -1448,8 +1448,22 @@ class AccountViewModel( isClosed: Boolean, isHidden: Boolean, isRestricted: Boolean, + hashtags: List, + geohashes: List, ) = launchSigner { - account.createRelayGroup(relay, groupId, name, about, picture, isPrivate, isClosed, isHidden, isRestricted) + account.createRelayGroup( + relay, + groupId, + name, + about, + picture, + isPrivate, + isClosed, + isHidden, + isRestricted, + hashtags, + geohashes, + ) } fun createRelayGroupInvite( @@ -1483,8 +1497,21 @@ class AccountViewModel( isClosed: Boolean, isHidden: Boolean, isRestricted: Boolean, + hashtags: List, + geohashes: List, ) = launchSigner { - account.editRelayGroupMetadata(channel, name, about, picture, isPrivate, isClosed, isHidden, isRestricted) + account.editRelayGroupMetadata( + channel, + name, + about, + picture, + isPrivate, + isClosed, + isHidden, + isRestricted, + hashtags, + geohashes, + ) } fun follow(users: List) = launchSigner { account.follow(users) } 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 index 1964e0ad49..9b2474236f 100644 --- 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 @@ -83,18 +83,24 @@ fun RelayGroupDiscoveryScreen( val viewModel: RelayGroupDiscoveryViewModel = viewModel() viewModel.init(accountViewModel.account) - val relays by viewModel.relays.collectAsStateWithLifecycle() + val constraints by viewModel.constraints.collectAsStateWithLifecycle() val groups by viewModel.groups.collectAsStateWithLifecycle() val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList .collectAsStateWithLifecycle() val favoriteRelays by accountViewModel.account.relayFeedsList.flow .collectAsStateWithLifecycle() - // Fan the directory subscription out to each relay in the current set while the screen - // is visible; each is a lifecycle-aware per-relay REQ that EOSEs and dedupes by relay. - relays.forEach { relay -> + // Fan the directory subscription out to each relay in the current set while the screen is + // visible; each is a lifecycle-aware per-relay REQ (narrowed by the relay's constraint for + // topic/geo filters) that EOSEs and dedupes by relay. + constraints.forEach { (relay, constraint) -> key(relay) { - RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) + RelayGroupDirectorySubscription( + relay, + accountViewModel.dataSources().relayGroupDirectory, + accountViewModel, + constraint, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt index 9e672f1333..7d7ab4f2a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt @@ -26,18 +26,13 @@ import androidx.lifecycle.viewModelScope 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.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.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -50,41 +45,23 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn /** - * The relays a top-nav filter resolves to. A NIP-29 group's kind-39000 is relay-signed - * (not authored by follows, not hashtag/geo-tagged), so — unlike the author-based feeds — - * the ONLY thing a filter contributes to group discovery is its RELAY SET. We take the - * keys of whatever per-relay set the selected [TopFilter] produced and query kind 39000 - * on them. - */ -fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = - when (this) { - is GlobalTopNavPerRelayFilterSet -> set.keys - is AllFollowsTopNavPerRelayFilterSet -> set.keys - is AuthorsTopNavPerRelayFilterSet -> set.keys - is HashtagTopNavPerRelayFilterSet -> set.keys - is LocationTopNavPerRelayFilterSet -> set.keys - is AllCommunitiesTopNavPerRelayFilterSet -> set.keys - is SingleCommunityTopNavPerRelayFilterSet -> set.keys - is MutedAuthorsTopNavPerRelayFilterSet -> set.keys - // A single relay chip (includes a starred favorite relay). - is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) - // DVM algo-feed selections carry no group-hosting relays. - else -> emptySet() - } - -/** - * Drives the Relay Groups discovery feed. The top bar's [FeedFilterSpinner] writes the - * selection to the account's persisted `defaultRelayGroupsDiscoveryFollowList` (Global / - * Follows / a followed hashtag or geohash / a specific relay — including favorite relays, - * which surface as relay chips), exactly like every other feed. Because a group's kind-39000 - * is relay-signed, the only thing the filter contributes is its RELAY SET ([relayKeys]); the - * feed is every group those relays host, read from [LocalCache] as directory events arrive. + * Drives the Relay Groups discovery feed. The top bar's [com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner] + * writes the selection to the account's persisted `defaultRelayGroupsDiscoveryFollowList` (Global / + * Follows / a followed hashtag or geohash / a specific relay — including favorite relays, which + * surface as relay chips), exactly like every other feed. + * + * Unlike the note feeds, a group's kind-39000 is relay-signed, so the resolved filter is turned into + * a per-relay [GroupDiscoveryConstraint] ([toGroupConstraints]): the relays to query, plus how each + * group on them is matched (relay-key follow / follow-is-admin / follow-is-member for people filters, + * `#t`/`#g` tag match for topic/geo filters, or every group for Global). The feed is every cached + * group that satisfies its host relay's constraint, re-scanned as directory events arrive. */ @Stable class RelayGroupDiscoveryViewModel : ViewModel() { private lateinit var account: Account - lateinit var relays: StateFlow> + /** The relay → constraint map the selected top-nav filter resolved to. */ + lateinit var constraints: StateFlow> private set lateinit var groups: StateFlow> @@ -95,31 +72,42 @@ class RelayGroupDiscoveryViewModel : ViewModel() { if (this::account.isInitialized) return account = acc - relays = + constraints = account.liveRelayGroupsDiscoveryFollowListsPerRelay - .map { it.relayKeys() } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) + .map { it.toGroupConstraints() } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) groups = - relays - .flatMapLatest { relaySet -> - // Re-scan the cache whenever a new kind-39000 lands; the initial emit renders - // whatever's already cached for this relay set immediately. + constraints + .flatMapLatest { byRelay -> + // Re-scan the cache whenever any directory event lands — metadata (39000) OR + // a roster change (39001/39002), since the people match reads admins/members. + // The emitted list is ignored; matchingGroups reads the cache directly. The + // initial emit renders whatever's already cached for these relays immediately. LocalCache - .observeEvents(Filter(kinds = listOf(GroupMetadataEvent.KIND))) - .onStart { emit(emptyList()) } - .map { groupsOnRelays(relaySet) } + .observeEvents( + Filter( + kinds = + listOf( + GroupMetadataEvent.KIND, + GroupAdminsEvent.KIND, + GroupMembersEvent.KIND, + ), + ), + ).onStart { emit(emptyList()) } + .map { matchingGroups(byRelay) } }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) } - private fun groupsOnRelays(relaySet: Set): List = - if (relaySet.isEmpty()) { + private fun matchingGroups(byRelay: Map): List = + if (byRelay.isEmpty()) { emptyList() } else { LocalCache.relayGroupChannels - .filter { key, channel -> key.relayUrl in relaySet && channel.event != null } - .sortedWith( + .filter { key, channel -> + channel.event != null && byRelay[key.relayUrl]?.matches(channel) == true + }.sortedWith( compareByDescending { it.memberCount() } .thenBy { it.toBestDisplayName().lowercase() }, ) 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 index eead0bddb6..ebf4e329da 100644 --- 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 @@ -291,6 +291,42 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { 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), 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 index ab4fdf36bd..5dc7735f39 100644 --- 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 @@ -75,6 +75,12 @@ class RelayGroupMetadataViewModel : ViewModel() { 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) @@ -126,8 +132,29 @@ class RelayGroupMetadataViewModel : ViewModel() { 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 } @@ -179,6 +206,8 @@ class RelayGroupMetadataViewModel : ViewModel() { picture.value.text .trim() .ifBlank { null } + val hashtags = parseTopics() + val geohashes = parseGeohashes() val existing = channel if (existing == null) { account.createRelayGroup( @@ -191,6 +220,8 @@ class RelayGroupMetadataViewModel : ViewModel() { isClosed = isClosed, isHidden = isHidden, isRestricted = isRestricted, + hashtags = hashtags, + geohashes = geohashes, ) } else { account.editRelayGroupMetadata( @@ -202,6 +233,8 @@ class RelayGroupMetadataViewModel : ViewModel() { isClosed = isClosed, isHidden = isHidden, isRestricted = isRestricted, + hashtags = hashtags, + geohashes = geohashes, ) } } 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..ae560deb4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt @@ -0,0 +1,152 @@ +/* + * 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.RelayGroupChannel +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.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * The per-type match a discovery filter applies to a [RelayGroupChannel] on ONE relay. + * + * A NIP-29 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 just 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]). + */ +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) } + } +} + +/** + * Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. 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). Filter kinds + * that don't map to a people/topic/geo dimension (communities, muted-only) fall back to + * [GroupDiscoveryConstraint.AllGroups] — still constraining the RELAY set, just not the people. + */ +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)) } + } + when { + lenses.isEmpty() -> GroupDiscoveryConstraint.AllGroups + lenses.size == 1 -> lenses.first() + else -> GroupDiscoveryConstraint.AnyOf(lenses) + } + } + // Community / muted-authors / DVM algo selections carry no group-hosting dimension; + // show every group the resolved relays host (or nothing when there are no relays). + else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups } + } + +/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */ +private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = + when (this) { + is GlobalTopNavPerRelayFilterSet -> set.keys + is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) + is AuthorsTopNavPerRelayFilterSet -> set.keys + is HashtagTopNavPerRelayFilterSet -> set.keys + is LocationTopNavPerRelayFilterSet -> set.keys + is AllFollowsTopNavPerRelayFilterSet -> set.keys + is AllCommunitiesTopNavPerRelayFilterSet -> set.keys + is SingleCommunityTopNavPerRelayFilterSet -> set.keys + is MutedAuthorsTopNavPerRelayFilterSet -> set.keys + // DVM algo-feed selections carry no group-hosting relays. + else -> emptySet() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt index e39e019fdc..89fe60559d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt @@ -24,19 +24,29 @@ import com.vitorpamplona.amethyst.commons.relayClient.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.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint 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.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag 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. */ +/** + * One screen's request for a single relay's group directory. [constraint] narrows the REQ + * per top-nav filter: [GroupDiscoveryConstraint.ByHashtags]/[GroupDiscoveryConstraint.ByGeohashes] + * query only kind-39000 tagged with the topic/geo (relay must copy `t`/`g` onto the 39000); + * every other constraint pulls the broad directory (39000-39003) since the people match needs + * the rosters. Defaults to [GroupDiscoveryConstraint.AllGroups] for the "browse a relay" screen. + */ class RelayGroupDirectoryQueryState( val relay: NormalizedRelayUrl, val account: Account, + val constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups, ) private val RELAY_GROUP_DIRECTORY_KINDS = @@ -76,18 +86,35 @@ class RelayGroupDirectorySubAssembler( override fun updateFilter( key: RelayGroupDirectoryQueryState, since: SincePerRelayMap?, - ): List = - listOf( - RelayBasedFilter( - relay = key.relay, - filter = + ): List { + val sinceTime = since?.get(key.relay)?.time + val filter = + when (val c = key.constraint) { + is GroupDiscoveryConstraint.ByHashtags -> + Filter( + kinds = listOf(GroupMetadataEvent.KIND), + tags = mapOf(HashtagTag.TAG_NAME to c.hashtags.map { it.lowercase() }), + limit = 500, + since = sinceTime, + ) + is GroupDiscoveryConstraint.ByGeohashes -> + Filter( + kinds = listOf(GroupMetadataEvent.KIND), + tags = mapOf(GeoHashTag.TAG_NAME to c.geohashes.map { it.lowercase() }), + limit = 500, + since = sinceTime, + ) + // AllGroups / ByPeople / AnyOf need the rosters (39001/39002) for the people + // match and member counts, so pull the whole directory unnarrowed. + else -> Filter( kinds = RELAY_GROUP_DIRECTORY_KINDS, limit = 500, - since = since?.get(key.relay)?.time, - ), - ), - ) + since = sinceTime, + ) + } + return listOf(RelayBasedFilter(relay = key.relay, filter = filter)) + } override fun id(key: RelayGroupDirectoryQueryState) = key.relay } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt index c33473ae25..9f01f66b68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt @@ -24,6 +24,7 @@ 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.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable @@ -31,10 +32,11 @@ fun RelayGroupDirectorySubscription( relay: NormalizedRelayUrl, dataSource: RelayGroupDirectoryFilterAssembler, accountViewModel: AccountViewModel, + constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups, ) { val state = - remember(accountViewModel.account, relay) { - RelayGroupDirectoryQueryState(relay, accountViewModel.account) + remember(accountViewModel.account, relay, constraint) { + RelayGroupDirectoryQueryState(relay, accountViewModel.account, constraint) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2f85dd4332..fc48c5f138 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1977,6 +1977,12 @@ 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. 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..5a6c8910b1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt @@ -0,0 +1,163 @@ +/* + * 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.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/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 09b7b07d42..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 @@ -28,6 +28,10 @@ import com.vitorpamplona.quartz.nip01Core.core.firstTagValue 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,6 +55,19 @@ class GroupMetadataEvent( fun picture() = tags.firstTagValue("picture") + /** + * 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() + + /** + * 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() + /** Only members can read. Presence of the `private` flag; absent = public read. */ fun isPrivate() = tags.hasTagName("private") @@ -108,6 +125,8 @@ class GroupMetadataEvent( 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) { @@ -119,6 +138,9 @@ class GroupMetadataEvent( 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/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/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index f5d3f54aae..33703c3ae3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -319,6 +319,58 @@ class Nip29ArmadaInteropTest { 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 }) From dc5bf6499a46c8c3dd292657f56742c2e3bf745d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 22:17:50 +0000 Subject: [PATCH 38/81] refactor: rebuild relay-group discovery on the canonical feed stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the discovery feed to match the gitRepositories package exactly — the shape every feed screen in the app uses — replacing the bespoke ViewModel + per-relay directory fan-out. New package layout (mirrors gitRepositories/): - dal/RelayGroupDiscoveryFeedFilter — AdditiveFeedFilter over the 39000 addressables; GroupDiscoveryConstraint (moved to its own file) supplies the relay-signed match (relay-key / admin / member, or #t/#g tag). - datasource/RelayGroupsDiscoveryFilter — makeRelayGroupsDiscoveryFilter per-type dispatch over IFeedTopNavPerRelayFilterSet. - datasource/subassemblies/ — FilterRelayGroupsGlobal / ByFollows / ByAuthors (+ muted) / ByHashtag / ByGeohashes / ByCommunity, plus the shared directory builder. Authors can't be a REQ constraint (a 39000 is relay-signed), so the people filters pull the directory per relay and the dal narrows locally; only topic/geo carry a relay-side #t/#g constraint. - datasource/RelayGroupsDiscoveryFilterAssembler + SubAssembler (PerUserAndFollowListEoseManager) + FilterAssemblerSubscription. Wiring, parallel to gitRepositories: - AccountFeedContentStates.relayGroupsDiscoveryFeed (+ update/delete/trim fan-out) - TopNavFilterState.relayGroupsDiscoveryRoutes - RelaySubscriptionsCoordinator.relayGroupsDiscovery - ScrollStateKeys.RELAY_GROUPS_DISCOVERY_SCREEN Screen now runs on FeedContentState + RefresheableBox + RenderFeedContentState (custom onLoaded rendering the joinable group cards). Old ViewModel deleted; the constraint additions to the directory assembler reverted (browse-a-relay path keeps the plain broad directory). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/screen/TopNavFilterState.kt | 23 +++ .../loggedIn/AccountFeedContentStates.kt | 5 + .../relayGroup/RelayGroupDiscoveryScreen.kt | 160 ++++++++++------ .../RelayGroupDiscoveryViewModel.kt | 115 ----------- .../dal/GroupDiscoveryConstraint.kt | 152 +++++++++++++++ .../dal/RelayGroupDiscoveryFeedFilter.kt | 180 +++++++----------- .../RelayGroupDirectoryFilterAssembler.kt | 47 +---- .../RelayGroupDirectorySubscription.kt | 6 +- .../datasource/RelayGroupsDiscoveryFilter.kt | 58 ++++++ .../RelayGroupsDiscoveryFilterAssembler.kt | 50 +++++ ...upsDiscoveryFilterAssemblerSubscription.kt | 48 +++++ .../RelayGroupsDiscoverySubAssembler.kt | 101 ++++++++++ .../FilterRelayGroupsByAuthors.kt | 60 ++++++ .../FilterRelayGroupsByCommunity.kt | 59 ++++++ .../FilterRelayGroupsByFollows.kt | 43 +++++ .../FilterRelayGroupsByGeohashes.kt | 71 +++++++ .../FilterRelayGroupsByHashtag.kt | 68 +++++++ .../FilterRelayGroupsDirectory.kt | 60 ++++++ .../subassemblies/FilterRelayGroupsGlobal.kt | 41 ++++ 21 files changed, 1023 insertions(+), 328 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt 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 1751ac47cb..1f83d9daa8 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 @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler 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 @@ -121,6 +122,7 @@ class RelaySubscriptionsCoordinator( val relayGroupRoster = RelayGroupRosterFilterAssembler(client) val relayGroupThreads = RelayGroupThreadsFilterAssembler(client) val relayGroupPreview = RelayGroupPreviewFilterAssembler(client) + val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -186,6 +188,7 @@ class RelaySubscriptionsCoordinator( relayGroupRoster, relayGroupThreads, relayGroupPreview, + relayGroupsDiscovery, account, accountForeground, home, 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/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 9a2b7b26af..8579921869 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 @@ -328,6 +328,24 @@ class TopNavFilterState( ) } + private val _relayGroupsDiscoveryRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Relay-group discovery routes to relays by author, hashtag and geohash, plus a + // favorite-relay chip; mirrors the kind3 catalog like the git-repositories feed. + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _podcastRoutes = combineTransform( livePeopleListsFlow, @@ -426,6 +444,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, 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 50cdfd204e..cb90c18215 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) @@ -231,6 +233,7 @@ class AccountFeedContentStates( picturesFeed.updateFeedWith(newNotes) workoutsFeed.updateFeedWith(newNotes) gitRepositoriesFeed.updateFeedWith(newNotes) + relayGroupsDiscoveryFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) publicChatsFeed.updateFeedWith(newNotes) @@ -293,6 +296,7 @@ class AccountFeedContentStates( picturesFeed.deleteFromFeed(newNotes) workoutsFeed.deleteFromFeed(newNotes) gitRepositoriesFeed.deleteFromFeed(newNotes) + relayGroupsDiscoveryFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) shortsFeed.deleteFromFeed(newNotes) publicChatsFeed.deleteFromFeed(newNotes) @@ -351,6 +355,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/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index 9b2474236f..8069eeda3b 100644 --- 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 @@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilledTonalButton @@ -41,8 +39,9 @@ import androidx.compose.material3.Scaffold 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.key +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,62 +50,66 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel 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.model.TopFilter +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.model.LocalCache 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.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.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupDirectorySubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent /** - * Discover NIP-29 groups across the relay set the top-bar filter resolves to. Mirrors the - * Git/Pictures feeds: a [FeedFilterSpinner] persists the selection to - * `defaultRelayGroupsDiscoveryFollowList` (Global / Follows / a followed hashtag or geohash / - * a specific relay — favorite relays show up as relay chips). The group directory query is - * fanned out to every relay in that set and each group is listed as a joinable card. + * 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, ) { - val viewModel: RelayGroupDiscoveryViewModel = viewModel() - viewModel.init(accountViewModel.account) + RelayGroupDiscoveryScreen( + feedContentState = accountViewModel.feedStates.relayGroupsDiscoveryFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} - val constraints by viewModel.constraints.collectAsStateWithLifecycle() - val groups by viewModel.groups.collectAsStateWithLifecycle() - val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList - .collectAsStateWithLifecycle() - val favoriteRelays by accountViewModel.account.relayFeedsList.flow - .collectAsStateWithLifecycle() - - // Fan the directory subscription out to each relay in the current set while the screen is - // visible; each is a lifecycle-aware per-relay REQ (narrowed by the relay's constraint for - // topic/geo filters) that EOSEs and dedupes by relay. - constraints.forEach { (relay, constraint) -> - key(relay) { - RelayGroupDirectorySubscription( - relay, - accountViewModel.dataSources().relayGroupDirectory, - accountViewModel, - constraint, - ) - } - } +@Composable +fun RelayGroupDiscoveryScreen( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedContentState) + WatchAccountForRelayGroupDiscovery(feedContentState, accountViewModel) + RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel) Scaffold( topBar = { - val options by accountViewModel.feedStates.feedListOptions.kind3GlobalPeopleRoutes + val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList + .collectAsStateWithLifecycle() + val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes .collectAsStateWithLifecycle() TopBarExtensibleWithBackButton( title = { @@ -131,38 +134,79 @@ fun RelayGroupDiscoveryScreen( ) }, ) { padding -> - if (groups.isEmpty()) { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text( - text = - if (selectedFilter == TopFilter.Global) { - stringRes(R.string.relay_group_discovery_empty) - } else { - stringRes(R.string.relay_group_discovery_empty_filtered) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(32.dp), - ) - } - } else { - val myPubkey = accountViewModel.userProfile().pubkeyHex - LazyColumn(Modifier.fillMaxSize().padding(padding)) { - items(groups, key = { it.groupId.toKey() }) { channel -> - RelayGroupDiscoveryCard( - channel = channel, - myPubkey = myPubkey, - isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays, + Column(Modifier.padding(padding)) { + 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() + LazyColumn(Modifier.fillMaxWidth(), state = listState) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + RelayGroupDiscoveryRow(item, accountViewModel, nav) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + }, ) - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } } } } +@Composable +private fun WatchAccountForRelayGroupDiscovery( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listName by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList + .collectAsStateWithLifecycle() + val perRelay by accountViewModel.account.liveRelayGroupsDiscoveryFollowListsPerRelay + .collectAsStateWithLifecycle() + + LaunchedEffect(listName, perRelay) { + feedContentState.checkKeysInvalidateDataAndSendToTop() + } +} + +/** + * 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. + */ +@Composable +private fun RelayGroupDiscoveryRow( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? GroupMetadataEvent ?: return + val relay = note.relays.firstOrNull() ?: return + val baseChannel = remember(event.groupId(), relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) } + + val channelState by baseChannel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val channel = channelState.channel as? RelayGroupChannel ?: baseChannel + + val favoriteRelays by accountViewModel.account.relayFeedsList.flow + .collectAsStateWithLifecycle() + + RelayGroupDiscoveryCard( + channel = channel, + myPubkey = accountViewModel.userProfile().pubkeyHex, + isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays, + accountViewModel = accountViewModel, + nav = nav, + ) +} + @Composable private fun RelayGroupDiscoveryCard( channel: RelayGroupChannel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt deleted file mode 100644 index 7d7ab4f2a6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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.Stable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn - -/** - * Drives the Relay Groups discovery feed. The top bar's [com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner] - * writes the selection to the account's persisted `defaultRelayGroupsDiscoveryFollowList` (Global / - * Follows / a followed hashtag or geohash / a specific relay — including favorite relays, which - * surface as relay chips), exactly like every other feed. - * - * Unlike the note feeds, a group's kind-39000 is relay-signed, so the resolved filter is turned into - * a per-relay [GroupDiscoveryConstraint] ([toGroupConstraints]): the relays to query, plus how each - * group on them is matched (relay-key follow / follow-is-admin / follow-is-member for people filters, - * `#t`/`#g` tag match for topic/geo filters, or every group for Global). The feed is every cached - * group that satisfies its host relay's constraint, re-scanned as directory events arrive. - */ -@Stable -class RelayGroupDiscoveryViewModel : ViewModel() { - private lateinit var account: Account - - /** The relay → constraint map the selected top-nav filter resolved to. */ - lateinit var constraints: StateFlow> - private set - - lateinit var groups: StateFlow> - private set - - @OptIn(ExperimentalCoroutinesApi::class) - fun init(acc: Account) { - if (this::account.isInitialized) return - account = acc - - constraints = - account.liveRelayGroupsDiscoveryFollowListsPerRelay - .map { it.toGroupConstraints() } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) - - groups = - constraints - .flatMapLatest { byRelay -> - // Re-scan the cache whenever any directory event lands — metadata (39000) OR - // a roster change (39001/39002), since the people match reads admins/members. - // The emitted list is ignored; matchingGroups reads the cache directly. The - // initial emit renders whatever's already cached for these relays immediately. - LocalCache - .observeEvents( - Filter( - kinds = - listOf( - GroupMetadataEvent.KIND, - GroupAdminsEvent.KIND, - GroupMembersEvent.KIND, - ), - ), - ).onStart { emit(emptyList()) } - .map { matchingGroups(byRelay) } - }.flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - } - - private fun matchingGroups(byRelay: Map): List = - if (byRelay.isEmpty()) { - emptyList() - } else { - LocalCache.relayGroupChannels - .filter { key, channel -> - channel.event != null && byRelay[key.relayUrl]?.matches(channel) == true - }.sortedWith( - compareByDescending { it.memberCount() } - .thenBy { it.toBestDisplayName().lowercase() }, - ) - } -} 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..ae560deb4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt @@ -0,0 +1,152 @@ +/* + * 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.RelayGroupChannel +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.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * The per-type match a discovery filter applies to a [RelayGroupChannel] on ONE relay. + * + * A NIP-29 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 just 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]). + */ +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) } + } +} + +/** + * Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. 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). Filter kinds + * that don't map to a people/topic/geo dimension (communities, muted-only) fall back to + * [GroupDiscoveryConstraint.AllGroups] — still constraining the RELAY set, just not the people. + */ +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)) } + } + when { + lenses.isEmpty() -> GroupDiscoveryConstraint.AllGroups + lenses.size == 1 -> lenses.first() + else -> GroupDiscoveryConstraint.AnyOf(lenses) + } + } + // Community / muted-authors / DVM algo selections carry no group-hosting dimension; + // show every group the resolved relays host (or nothing when there are no relays). + else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups } + } + +/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */ +private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = + when (this) { + is GlobalTopNavPerRelayFilterSet -> set.keys + is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) + is AuthorsTopNavPerRelayFilterSet -> set.keys + is HashtagTopNavPerRelayFilterSet -> set.keys + is LocationTopNavPerRelayFilterSet -> set.keys + is AllFollowsTopNavPerRelayFilterSet -> set.keys + is AllCommunitiesTopNavPerRelayFilterSet -> set.keys + is SingleCommunityTopNavPerRelayFilterSet -> set.keys + is MutedAuthorsTopNavPerRelayFilterSet -> set.keys + // DVM algo-feed selections carry no group-hosting relays. + else -> emptySet() + } 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 index ae560deb4a..ede929f2a4 100644 --- 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 @@ -21,132 +21,84 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -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.core.HexKey +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.ui.dal.AdditiveFeedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent /** - * The per-type match a discovery filter applies to a [RelayGroupChannel] on ONE relay. - * - * A NIP-29 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 just 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]). + * 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]. */ -sealed interface GroupDiscoveryConstraint { - fun matches(channel: RelayGroupChannel): Boolean +class RelayGroupDiscoveryFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code - /** Every group the relay hosts (Global, or a specific relay chip). */ - data object AllGroups : GroupDiscoveryConstraint { - override fun matches(channel: RelayGroupChannel) = channel.event != null + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value + + private fun constraints(): Map = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() + + override fun feed(): List { + val byRelay = constraints() + val notes = + LocalCache.addressables.filterIntoSet(GroupMetadataEvent.KIND) { _, note -> + matches(note, byRelay) + } + return sort(notes) } - /** 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 } + override fun applyFilter(newItems: Set): Set { + val byRelay = constraints() + return newItems.filterTo(HashSet()) { matches(it, byRelay) } + } + + /** + * The group qualifies when it was served by a relay in the filter's set AND that relay's + * constraint accepts it. The 39000 note carries its own relay-signed metadata; its roster + * (admins/members) lives on the [RelayGroupChannel] keyed by (serving relay + group id), + * which [LocalCache.consume] created when the metadata arrived. + */ + private fun matches( + note: Note, + byRelay: Map, + ): Boolean { + if (byRelay.isEmpty()) return false + val event = note.event as? GroupMetadataEvent ?: return false + return note.relays.any { relay -> + val constraint = byRelay[relay] ?: return@any false + val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(event.groupId(), relay)) + channel != null && constraint.matches(channel) } } - /** 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 + override fun sort(items: Set): List { + fun channelOf(note: Note): RelayGroupChannel? { + val event = note.event as? GroupMetadataEvent ?: return null + return note.relays.firstNotNullOfOrNull { + LocalCache.getRelayGroupChannelIfExists(GroupId(event.groupId(), it)) + } } - } - /** 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() } + val memberCount = items.associateWith { channelOf(it)?.memberCount() ?: 0 } - 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) } + return items.sortedWith( + compareByDescending { memberCount[it] } + .thenByDescending { it.createdAt() ?: 0L } + .thenBy { it.idHex }, + ) } } - -/** - * Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. 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). Filter kinds - * that don't map to a people/topic/geo dimension (communities, muted-only) fall back to - * [GroupDiscoveryConstraint.AllGroups] — still constraining the RELAY set, just not the people. - */ -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)) } - } - when { - lenses.isEmpty() -> GroupDiscoveryConstraint.AllGroups - lenses.size == 1 -> lenses.first() - else -> GroupDiscoveryConstraint.AnyOf(lenses) - } - } - // Community / muted-authors / DVM algo selections carry no group-hosting dimension; - // show every group the resolved relays host (or nothing when there are no relays). - else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups } - } - -/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */ -private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = - when (this) { - is GlobalTopNavPerRelayFilterSet -> set.keys - is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) - is AuthorsTopNavPerRelayFilterSet -> set.keys - is HashtagTopNavPerRelayFilterSet -> set.keys - is LocationTopNavPerRelayFilterSet -> set.keys - is AllFollowsTopNavPerRelayFilterSet -> set.keys - is AllCommunitiesTopNavPerRelayFilterSet -> set.keys - is SingleCommunityTopNavPerRelayFilterSet -> set.keys - is MutedAuthorsTopNavPerRelayFilterSet -> set.keys - // DVM algo-feed selections carry no group-hosting relays. - else -> emptySet() - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt index 89fe60559d..e39e019fdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt @@ -24,29 +24,19 @@ import com.vitorpamplona.amethyst.commons.relayClient.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.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint 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.nip01Core.tags.geohash.GeoHashTag -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag 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 a single relay's group directory. [constraint] narrows the REQ - * per top-nav filter: [GroupDiscoveryConstraint.ByHashtags]/[GroupDiscoveryConstraint.ByGeohashes] - * query only kind-39000 tagged with the topic/geo (relay must copy `t`/`g` onto the 39000); - * every other constraint pulls the broad directory (39000-39003) since the people match needs - * the rosters. Defaults to [GroupDiscoveryConstraint.AllGroups] for the "browse a relay" screen. - */ +/** One screen's request for the full channel directory of a single relay. */ class RelayGroupDirectoryQueryState( val relay: NormalizedRelayUrl, val account: Account, - val constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups, ) private val RELAY_GROUP_DIRECTORY_KINDS = @@ -86,35 +76,18 @@ class RelayGroupDirectorySubAssembler( override fun updateFilter( key: RelayGroupDirectoryQueryState, since: SincePerRelayMap?, - ): List { - val sinceTime = since?.get(key.relay)?.time - val filter = - when (val c = key.constraint) { - is GroupDiscoveryConstraint.ByHashtags -> - Filter( - kinds = listOf(GroupMetadataEvent.KIND), - tags = mapOf(HashtagTag.TAG_NAME to c.hashtags.map { it.lowercase() }), - limit = 500, - since = sinceTime, - ) - is GroupDiscoveryConstraint.ByGeohashes -> - Filter( - kinds = listOf(GroupMetadataEvent.KIND), - tags = mapOf(GeoHashTag.TAG_NAME to c.geohashes.map { it.lowercase() }), - limit = 500, - since = sinceTime, - ) - // AllGroups / ByPeople / AnyOf need the rosters (39001/39002) for the people - // match and member counts, so pull the whole directory unnarrowed. - else -> + ): List = + listOf( + RelayBasedFilter( + relay = key.relay, + filter = Filter( kinds = RELAY_GROUP_DIRECTORY_KINDS, limit = 500, - since = sinceTime, - ) - } - return listOf(RelayBasedFilter(relay = key.relay, filter = filter)) - } + since = since?.get(key.relay)?.time, + ), + ), + ) override fun id(key: RelayGroupDirectoryQueryState) = key.relay } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt index 9f01f66b68..c33473ae25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt @@ -24,7 +24,6 @@ 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.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable @@ -32,11 +31,10 @@ fun RelayGroupDirectorySubscription( relay: NormalizedRelayUrl, dataSource: RelayGroupDirectoryFilterAssembler, accountViewModel: AccountViewModel, - constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups, ) { val state = - remember(accountViewModel.account, relay, constraint) { - RelayGroupDirectoryQueryState(relay, accountViewModel.account, constraint) + remember(accountViewModel.account, relay) { + RelayGroupDirectoryQueryState(relay, accountViewModel.account) } 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..6118c05708 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt @@ -0,0 +1,58 @@ +/* + * 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.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.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 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..e88e5d509f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt @@ -0,0 +1,101 @@ +/* + * 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.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +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 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() + + return makeRelayGroupsDiscoveryFilter( + feedSettings, + since, + key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtIfFilled(), + ) + } + + 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() + } + }, + ) + + 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/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..d947567c4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.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.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.relay.client.pool.RelayBasedFilter + +// Authors can't be pushed into the REQ (a group's 39000 is signed by its relay, never a follow), +// so an author filter contributes only its RELAY set; the directory is pulled per relay and the +// dal keeps the groups whose relay-key/admins/members intersect the selected authors. + +fun filterRelayGroupsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set.flatMap { + filterRelayGroupsDirectory( + relay = it.key, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } +} + +fun filterRelayGroupsByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.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/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..48d5850b18 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.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.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +// A follows filter pulls the whole directory (metadata + rosters) from each relay the follow set +// routes to; the dal keeps groups where a follow is the relay key, an admin, or a member, and any +// topic/geo groups the AllFollows big-OR also selects (all present in the directory pull). +fun filterRelayGroupsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.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/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/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..807f6ecf52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.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.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 +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterRelayGroupsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + filterRelayGroupsDirectory( + relay = it.key, + since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo(), + ) + } +} From 7f10ff6faea91f22ec32b27d57d8a1ac62e20597 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 22:41:26 +0000 Subject: [PATCH 39/81] fix: narrow relay-group follows/authors REQ instead of broad directory pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A follows/authors filter no longer pulls every group on the relay and filters client-side. It now emits, per relay, the three narrowed REQs a relay-signed group is discoverable by: 1. {kinds:[39000], authors:} — the relay signing-key is a follow 2. {kinds:[39001,39002], #p:} — a follow is an admin/member 3. {kinds:[39000], #d:} — metadata backfill for (2) (3) reads the group-ids of cached 39001/39002 rosters that already mention a follow (empty on the first pass, filled once (2)'s events land and the sub-assembler re-invalidates), since a #p roster hit doesn't carry the 39000. ByFollows routes through the shared per-relay author builder (mirrors Git); muted-authors likewise. Global / communities keep the broad directory pull, since they carry no author dimension to narrow on. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../FilterRelayGroupsByAuthors.kt | 83 +++++++++++++++++-- .../FilterRelayGroupsByFollows.kt | 14 ++-- 2 files changed, 85 insertions(+), 12 deletions(-) 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 index d947567c4c..b6713932cb 100644 --- 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 @@ -20,14 +20,85 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies +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 -// Authors can't be pushed into the REQ (a group's 39000 is signed by its relay, never a follow), -// so an author filter contributes only its RELAY set; the directory is pulled per relay and the -// dal keeps the groups whose relay-key/admins/members intersect the selected authors. +/** + * 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 cache (empty on the first pass, filled once (2)'s events land and + * the sub-assembler re-invalidates). + */ +fun filterRelayGroupsByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): 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 = + LocalCache + .getRelayGroupChannelsOnRelay(relay) + .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, @@ -37,8 +108,9 @@ fun filterRelayGroupsByAuthors( if (authorSet.set.isEmpty()) return emptyList() return authorSet.set.flatMap { - filterRelayGroupsDirectory( + filterRelayGroupsByAuthors( relay = it.key, + authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, ) } @@ -52,8 +124,9 @@ fun filterRelayGroupsByMutedAuthors( if (authorSet.set.isEmpty()) return emptyList() return authorSet.set.flatMap { - filterRelayGroupsDirectory( + filterRelayGroupsByAuthors( relay = it.key, + authors = it.value.authors, 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 index 48d5850b18..c5b2b43a9d 100644 --- 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 @@ -24,9 +24,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavP import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -// A follows filter pulls the whole directory (metadata + rosters) from each relay the follow set -// routes to; the dal keeps groups where a follow is the relay key, an admin, or a member, and any -// topic/geo groups the AllFollows big-OR also selects (all present in the directory pull). +// A follows filter routes to each relay the follow set uses and asks, per relay, for the three +// ways a follow surfaces in a relay-signed group: the relay signing-key is a follow, or a follow +// is an admin (39001) or a member (39002). See [filterRelayGroupsByAuthors]. fun filterRelayGroupsByFollows( followsSet: AllFollowsTopNavPerRelayFilterSet, since: SincePerRelayMap?, @@ -35,9 +35,9 @@ fun filterRelayGroupsByFollows( if (followsSet.set.isEmpty()) return emptyList() return followsSet.set.flatMap { - filterRelayGroupsDirectory( - relay = it.key, - since = since?.get(it.key)?.time ?: defaultSince, - ) + val relaySince = since?.get(it.key)?.time ?: defaultSince + it.value.authors?.let { authors -> + filterRelayGroupsByAuthors(it.key, authors, relaySince) + } ?: emptyList() } } From 5c39b56ce6d5e2d066762e9d09f3e9de8eff6683 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 23:10:35 +0000 Subject: [PATCH 40/81] feat: warm visible discovery groups + elevate the discovery card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the discovery feed's rendering: - Preload: each on-screen group row now mounts RelayGroupPreviewSubscription, so the newest ~15 chat messages / threads are prefetched to the group's host relay while the card is visible — tapping a group opens an already-populated screen instead of loading from scratch. Lifecycle-aware and bounded to the rows the LazyColumn actually composes; tears down as they scroll off. Same warm-up the inline group-link card already used. - Visuals: the flat list row is now an ElevatedCard matching the inline card — primary-ring avatar, member-count icon, status pill, description — while keeping the discovery-only actions (favorite-relay star + Join). Rows stay reactive: they observe the channel metadata flow, so name/picture/members/ membership fill in and update live without moving the layout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupDiscoveryScreen.kt | 181 ++++++++++-------- 1 file changed, 106 insertions(+), 75 deletions(-) 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 index 8069eeda3b..c1cd19ade0 100644 --- 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 @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup -import androidx.compose.foundation.clickable +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,8 +31,9 @@ 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.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -69,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner 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.RelayGroupPreviewSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -148,7 +150,6 @@ fun RelayGroupDiscoveryScreen( LazyColumn(Modifier.fillMaxWidth(), state = listState) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> RelayGroupDiscoveryRow(item, accountViewModel, nav) - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } }, @@ -177,7 +178,9 @@ private fun WatchAccountForRelayGroupDiscovery( /** * 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. + * 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( @@ -189,6 +192,9 @@ private fun RelayGroupDiscoveryRow( val relay = note.relays.firstOrNull() ?: return val baseChannel = remember(event.groupId(), relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) } + // Prefetch the group's recent content so opening the card lands on a populated screen. + RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel) + val channelState by baseChannel .flow() .metadata.stateFlow @@ -220,56 +226,107 @@ private fun RelayGroupDiscoveryCard( val memberCount = channel.memberCount() val relay = channel.groupId.relayUrl - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + ElevatedCard( + onClick = { nav.nav(routeFor(channel)) }, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp), ) { - RobohashFallbackAsyncImage( - robot = channel.groupId.id, - model = channel.profilePicture(), - contentDescription = channel.toBestDisplayName(), - modifier = Modifier.size(48.dp).clip(CircleShape), - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = autoPlayGif, - ) - - Column( - Modifier - .weight(1f) - .clickable { nav.nav(routeFor(channel)) }, - ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = channel.toBestDisplayName(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + 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, ) - DiscoveryStatusPill(channel) - } - val subtitle = - if (memberCount > 0) { - "${relay.displayUrl()} · ${pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)}" - } else { - relay.displayUrl() + + 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) + } + 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 = relay.displayUrl(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + + // Star the group's host relay so it (and its other groups) surface under the relay + // chip in the filter — the favorite is the kind-10012 relay-feeds list. + IconButton(onClick = { + if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay) + }) { + Icon( + symbol = if (isFavoriteRelay) MaterialSymbols.Star else MaterialSymbols.StarBorder, + contentDescription = stringRes(R.string.relay_group_favorite_relay), + tint = if (isFavoriteRelay) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + + 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)) + } + } + } + channel.summary()?.takeIf { it.isNotBlank() }?.let { Text( text = it, @@ -277,36 +334,10 @@ private fun RelayGroupDiscoveryCard( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) } } - - // Star the group's host relay so it (and its other groups) surface under the relay - // chip in the filter — the favorite is the kind-10012 relay-feeds list. - IconButton(onClick = { - if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay) - }) { - Icon( - symbol = if (isFavoriteRelay) MaterialSymbols.Star else MaterialSymbols.StarBorder, - contentDescription = stringRes(R.string.relay_group_favorite_relay), - tint = if (isFavoriteRelay) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } - - 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)) - } - } } } From 6a663781de3c33378d3c9f60d5d67dc236ab64af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 23:48:49 +0000 Subject: [PATCH 41/81] fix: address relay-group audit findings (correctness, perf, consistency) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Discovery sort no longer reads createdAt live in the comparator (TimSort "contract violated" crash risk); orders by member count then the shared sortedByDefaultFeedOrder snapshot. - One shared resolver (relayGroupDiscoveryChannelFor) is used by the feed match, sort AND the row, so a 39000 seen on >1 relay always binds one channel — no more "sorted by relay B, rendered with relay A's empty roster". - Roster (39001/39002) arrivals now re-inject the group's 39000 into the feed and re-invalidate the datasource, so a group where a follow is an admin/ member surfaces instead of staying hidden / frozen at 0 members. - Group replies route to the group's host (resolved from the channel), never falling through to signAndComputeBroadcast — fixes the outbox leak when the parent note had no relay provenance. - Messages list (inline mode) updates group rows incrementally: the additive path now handles group-scoped messages, not just public/ephemeral/DM. - Optimistic null-relay group sends attach only to an unambiguous single channel, so a message to the "_" group on relay A no longer bleeds into "_" on relay B. Consistency: - AllFollows discovery also REQs #t/#g (not just authors), matching the local AnyOf constraint; muted-authors maps to ByPeople; community stays AllGroups — fetch and display now agree. - Global drops the 1-week `since` floor so long-lived group metadata is fetched. Performance: - #d metadata backfill scans the group cache once per filter assembly (grouped by relay), not once per relay. - Discovery rows warm content only; the directory subscription already streams metadata/rosters for the relay. - memberCount is memoized (members ∪ admins recomputed on roster change, not per read); thread re-sort extracted. - Discovery list gets rememberFeedContentPadding, contentType and animateItem. CLI: - `relaygroup edit` preserves current name/about/tags when only a flag changes. Cleanup: dropped dead relayKeys() branches and the redundant AnyOf single-lens collapse in the discovery constraint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/model/LocalCache.kt | 21 ++++-- .../nip22Comments/CommentPostViewModel.kt | 23 ++++-- .../relayGroup/RelayGroupDiscoveryScreen.kt | 37 ++++++--- .../dal/GroupDiscoveryConstraint.kt | 39 ++++------ .../dal/RelayGroupDiscoveryFeedFilter.kt | 75 ++++++++++++------- .../RelayGroupPreviewFilterAssembler.kt | 5 +- .../RelayGroupPreviewSubscription.kt | 11 ++- .../RelayGroupsDiscoverySubAssembler.kt | 13 ++++ .../FilterRelayGroupsByAuthors.kt | 22 +++++- .../FilterRelayGroupsByFollows.kt | 25 +++++-- .../subassemblies/FilterRelayGroupsGlobal.kt | 6 +- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 61 ++++++++++++++- .../commands/RelayGroupModerationCommands.kt | 8 +- .../nip29RelayGroups/RelayGroupChannel.kt | 22 +++++- 14 files changed, 267 insertions(+), 101 deletions(-) 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 76d0b18c1d..922383eb08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1813,12 +1813,15 @@ object LocalCache : ILocalCache, ICacheProvider { // 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 to every already-open channel with - // this group id — normally the exact room being composed in — so the - // message appears immediately. Don't fabricate a channel from a guessed - // relay; the host relay's later echo attaches it to the canonical key. - relayGroupChannels.filter { key, _ -> key.id == groupId }.forEach { it.addNote(note, null) } + // 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) } } @@ -1838,7 +1841,11 @@ object LocalCache : ILocalCache, ICacheProvider { if (relay != null) { getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addThread(note) } else { - relayGroupChannels.filter { key, _ -> key.id == groupId }.forEach { it.addThread(note) } + // See attachToRelayGroupIfScoped: only attach when the group id is unambiguous. + relayGroupChannels + .filter { key, _ -> key.id == groupId } + .singleOrNull() + ?.addThread(note) } } 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 77d54cbd59..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 @@ -527,16 +527,27 @@ open class CommentPostViewModel : // 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 = - replyingTo - ?.takeIf { it.event?.isGroupScoped() == true } - ?.relays - ?.takeIf { it.isNotEmpty() } + 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 (groupHostRelays != null) { - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { groupHostRelays } + } 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) } 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 index c1cd19ade0..c2c6b3c1fd 100644 --- 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 @@ -57,7 +57,7 @@ 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.model.LocalCache +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState @@ -70,12 +70,12 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner 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.dal.relayGroupDiscoveryChannelFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewSubscription 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.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip29RelayGroups.GroupId -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent /** * Discover NIP-29 groups across the relay set the top-bar filter resolves to. Built on the shared @@ -147,9 +147,17 @@ fun RelayGroupDiscoveryScreen( routeForLastRead = null, onLoaded = { loaded -> val items by loaded.feed.collectAsStateWithLifecycle() - LazyColumn(Modifier.fillMaxWidth(), state = listState) { - itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - RelayGroupDiscoveryRow(item, accountViewModel, nav) + 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(), accountViewModel, nav) } } }, @@ -185,15 +193,18 @@ private fun WatchAccountForRelayGroupDiscovery( @Composable private fun RelayGroupDiscoveryRow( note: Note, + modifier: Modifier, accountViewModel: AccountViewModel, nav: INav, ) { - val event = note.event as? GroupMetadataEvent ?: return - val relay = note.relays.firstOrNull() ?: return - val baseChannel = remember(event.groupId(), relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) } + // 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. - RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel) + // 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. + RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel, contentOnly = true) val channelState by baseChannel .flow() @@ -206,6 +217,7 @@ private fun RelayGroupDiscoveryRow( RelayGroupDiscoveryCard( channel = channel, + modifier = modifier, myPubkey = accountViewModel.userProfile().pubkeyHex, isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays, accountViewModel = accountViewModel, @@ -216,6 +228,7 @@ private fun RelayGroupDiscoveryRow( @Composable private fun RelayGroupDiscoveryCard( channel: RelayGroupChannel, + modifier: Modifier, myPubkey: String, isFavoriteRelay: Boolean, accountViewModel: AccountViewModel, @@ -229,7 +242,7 @@ private fun RelayGroupDiscoveryCard( ElevatedCard( onClick = { nav.nav(routeFor(channel)) }, elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp), + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp), ) { Column(Modifier.fillMaxWidth().padding(12.dp)) { Row( 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 index ae560deb4a..a819feadba 100644 --- 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 @@ -102,9 +102,10 @@ sealed interface GroupDiscoveryConstraint { /** * Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. 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). Filter kinds - * that don't map to a people/topic/geo dimension (communities, muted-only) fall back to + * 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) { @@ -124,29 +125,15 @@ fun IFeedTopNavPerRelayFilterSet.toGroupConstraints(): Map GroupDiscoveryConstraint.AllGroups - lenses.size == 1 -> lenses.first() - else -> GroupDiscoveryConstraint.AnyOf(lenses) - } + if (lenses.isEmpty()) GroupDiscoveryConstraint.AllGroups else GroupDiscoveryConstraint.AnyOf(lenses) } - // Community / muted-authors / DVM algo selections carry no group-hosting dimension; - // show every group the resolved relays host (or nothing when there are no relays). - else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups } - } - -/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */ -private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = - when (this) { - is GlobalTopNavPerRelayFilterSet -> set.keys - is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) - is AuthorsTopNavPerRelayFilterSet -> set.keys - is HashtagTopNavPerRelayFilterSet -> set.keys - is LocationTopNavPerRelayFilterSet -> set.keys - is AllFollowsTopNavPerRelayFilterSet -> set.keys - is AllCommunitiesTopNavPerRelayFilterSet -> set.keys - is SingleCommunityTopNavPerRelayFilterSet -> set.keys - is MutedAuthorsTopNavPerRelayFilterSet -> set.keys - // DVM algo-feed selections carry no group-hosting relays. - else -> emptySet() + // 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 index ede929f2a4..79d02b87f1 100644 --- 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 @@ -27,10 +27,24 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet 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.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]) @@ -63,42 +77,53 @@ class RelayGroupDiscoveryFeedFilter( override fun applyFilter(newItems: Set): Set { val byRelay = constraints() - return newItems.filterTo(HashSet()) { matches(it, byRelay) } + 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 it was served by a relay in the filter's set AND that relay's - * constraint accepts it. The 39000 note carries its own relay-signed metadata; its roster - * (admins/members) lives on the [RelayGroupChannel] keyed by (serving relay + group id), - * which [LocalCache.consume] created when the metadata arrived. + * 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 { if (byRelay.isEmpty()) return false - val event = note.event as? GroupMetadataEvent ?: return false - return note.relays.any { relay -> - val constraint = byRelay[relay] ?: return@any false - val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(event.groupId(), relay)) - channel != null && constraint.matches(channel) - } + val channel = relayGroupDiscoveryChannelFor(note) ?: 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 { - fun channelOf(note: Note): RelayGroupChannel? { - val event = note.event as? GroupMetadataEvent ?: return null - return note.relays.firstNotNullOfOrNull { - LocalCache.getRelayGroupChannelIfExists(GroupId(event.groupId(), it)) - } - } - - val memberCount = items.associateWith { channelOf(it)?.memberCount() ?: 0 } - - return items.sortedWith( - compareByDescending { memberCount[it] } - .thenByDescending { it.createdAt() ?: 0L } - .thenBy { it.idHex }, - ) + // 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/RelayGroupPreviewFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt index cfcac1c20f..dbe3f0c048 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt @@ -38,6 +38,8 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** One on-screen group card's request to warm a single group. */ class RelayGroupPreviewQueryState( val channel: RelayGroupChannel, + /** When true, prefetch only recent content — the caller's screen already streams metadata. */ + val contentOnly: Boolean = false, ) /** Newest content kinds we prefetch so opening the card lands on populated screens. */ @@ -78,7 +80,8 @@ class RelayGroupPreviewSubAssembler( since: SincePerRelayMap?, ): List { val groupId = key.channel.groupId - return filterMetadataToRelayGroup(key.channel, since) + + val metadata = if (key.contentOnly) emptyList() else filterMetadataToRelayGroup(key.channel, since) + return metadata + RelayBasedFilter( relay = groupId.relayUrl, filter = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt index bc1a4f8cdf..b0bac400c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt @@ -26,16 +26,21 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -/** Mount on an inline group-link card to warm its metadata + recent content. */ +/** + * 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 RelayGroupPreviewSubscription( channel: RelayGroupChannel, dataSource: RelayGroupPreviewFilterAssembler, accountViewModel: AccountViewModel, + contentOnly: Boolean = false, ) { val state = - remember(channel.groupId) { - RelayGroupPreviewQueryState(channel) + remember(channel.groupId, contentOnly) { + RelayGroupPreviewQueryState(channel, contentOnly) } 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 index e88e5d509f..dde3f5469d 100644 --- 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 @@ -20,6 +20,7 @@ */ 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.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager @@ -27,6 +28,8 @@ 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.nip29RelayGroups.metadata.GroupAdminsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -86,6 +89,16 @@ class RelayGroupsDiscoverySubAssembler( 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() + } + } + }, ) return super.newSub(key) 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 index b6713932cb..812a427e61 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 @@ -34,19 +35,29 @@ 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 cache (empty on the first pass, filled once (2)'s events land and - * the sub-assembler re-invalidates). + * 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() @@ -79,8 +90,7 @@ fun filterRelayGroupsByAuthors( // (3) backfill 39000 metadata for the roster-discovered groups already known on this relay. val rosterGroupIds = - LocalCache - .getRelayGroupChannelsOnRelay(relay) + cachedChannels .filter { channel -> channel.admins.any { it.pubKey in authors } || channel.members.any { it in authors } } .map { it.groupId.id } if (rosterGroupIds.isNotEmpty()) { @@ -107,11 +117,13 @@ fun filterRelayGroupsByAuthors( ): 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(), ) } } @@ -123,11 +135,13 @@ fun filterRelayGroupsByMutedAuthors( ): 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/FilterRelayGroupsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt index c5b2b43a9d..64e5295a7e 100644 --- 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 @@ -24,9 +24,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavP import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -// A follows filter routes to each relay the follow set uses and asks, per relay, for the three -// ways a follow surfaces in a relay-signed group: the relay signing-key is a follow, or a follow -// is an admin (39001) or a member (39002). See [filterRelayGroupsByAuthors]. +// 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?, @@ -34,10 +35,20 @@ fun filterRelayGroupsByFollows( ): List { if (followsSet.set.isEmpty()) return emptyList() + val channelsByRelay = relayGroupChannelsByRelay() return followsSet.set.flatMap { - val relaySince = since?.get(it.key)?.time ?: defaultSince - it.value.authors?.let { authors -> - filterRelayGroupsByAuthors(it.key, authors, relaySince) - } ?: emptyList() + 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/FilterRelayGroupsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt index 807f6ecf52..144b6061ad 100644 --- 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 @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.utils.TimeUtils fun filterRelayGroupsGlobal( relays: GlobalTopNavPerRelayFilterSet, @@ -32,10 +31,13 @@ fun filterRelayGroupsGlobal( ): 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 ?: TimeUtils.oneWeekAgo(), + since = since?.get(it.key)?.time ?: defaultSince, ) } } 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 cf77e26ffd..9d0eb04e56 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 @@ -37,6 +37,8 @@ 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 class ChatroomListKnownFeedFilter( val account: Account, @@ -126,8 +128,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 } @@ -182,6 +189,22 @@ class ChatroomListKnownFeedFilter( } } + newRelevantRelayGroups.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + val oldGroupId = oldNote.event?.takeIf { it.isGroupScoped() }?.groupId() + if (newNotePair.key == oldGroupId) { + 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) } @@ -192,11 +215,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() } } @@ -249,6 +282,28 @@ class ChatroomListKnownFeedFilter( return newRelevantEphemeralChats } + /** Latest message per joined NIP-29 group (inline view mode), keyed by group id. */ + private fun filterRelevantRelayGroupMessages( + newItems: Set, + account: Account, + ): MutableMap { + if (account.settings.relayGroupViewMode.value != RelayGroupViewMode.INLINE) return mutableMapOf() + val joinedGroupIds = + account.relayGroupList.liveRelayGroupList.value + .mapTo(HashSet()) { it.groupId } + val result = mutableMapOf() + newItems.forEach { newNote -> + val gid = newNote.event?.takeIf { it.isGroupScoped() }?.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 + } + } + } + return result + } + private fun filterRelevantPrivateMessages( newItems: Set, account: Account, 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 index dd9bbcef09..02e72eae81 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -91,13 +91,17 @@ object RelayGroupModerationCommands { (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"), - about = args.flag("about"), + 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)) 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 index e504a7fe66..1e479af558 100644 --- 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 @@ -68,6 +68,16 @@ class RelayGroupChannel( 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 @@ -78,17 +88,21 @@ class RelayGroupChannel( 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) - _threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L } + republishThreads() } fun removeThread(note: Note) { if (!threadNotes.containsKey(note.idHex)) return threadNotes.remove(note.idHex) - _threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L } + republishThreads() } fun threadCount(): Int = threadNotes.size() @@ -130,6 +144,7 @@ class RelayGroupChannel( if (event.createdAt <= membersUpdatedAt) return members = event.members().toSet() membersUpdatedAt = event.createdAt + recomputeAllMembers() updateChannelInfo() } @@ -137,6 +152,7 @@ class RelayGroupChannel( if (event.createdAt <= adminsUpdatedAt) return admins = event.admins() adminsUpdatedAt = event.createdAt + recomputeAllMembers() updateChannelInfo() } @@ -151,7 +167,7 @@ class RelayGroupChannel( } /** Number of known members (admins are members too). */ - fun memberCount(): Int = (members + admins.map { it.pubKey }).size + fun memberCount(): Int = allMembers.size /** * The relay's view of [pubkey]'s membership, from the signed admin/member From c97e697ef0f791b1e24daa85bdd24d20915b038f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:31:02 +0000 Subject: [PATCH 42/81] refactor: unify Relay Groups onto the discovery feed The top-level "Relay Groups" tab was a thin server-list home screen while a separate "Find Groups" discovery feed did the real work. They're now one screen: the discovery feed IS the Relay Groups tab, defaulting to a "My Groups" filter. - Route.RelayGroups now renders the discovery feed (top-level DisappearingScaffold + AppBottomBar + drawer top bar with the filter spinner and browse action). - "My Groups" (TopFilter.Mine) lists the groups you've joined. These live on their host relays (kind 10009), not your outbox, so the filter scans the cache for groups where you're the relay-key / an admin / a member; the joined rosters are kept live by RelayGroupRosterSubscription mounted on the screen. - Per-relay "server" browsing is still available via the relay chips in the filter; the grouped server rail still shows in the Messages tab (GROUPED mode). - Default discovery filter is now Mine (was Global); the "Mine" chip is back in the route list. - Deleted RelayGroupsHomeScreen and the redundant Route.RelayGroupDiscovery; the Messages "Find groups" row and everything else point at Route.RelayGroups. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/LocalPreferences.kt | 2 +- .../amethyst/model/AccountSettings.kt | 2 +- .../amethyst/ui/navigation/AppNavigation.kt | 10 +- .../amethyst/ui/navigation/routes/Routes.kt | 2 - .../amethyst/ui/screen/TopNavFilterState.kt | 6 +- .../relayGroup/RelayGroupDiscoveryScreen.kt | 92 +++++++++++++------ .../relayGroup/RelayGroupServerList.kt | 4 +- .../relayGroup/RelayGroupsHomeScreen.kt | 85 ----------------- .../dal/RelayGroupDiscoveryFeedFilter.kt | 19 +++- 9 files changed, 89 insertions(+), 133 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 71f73471b3..b1e132d3e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -962,7 +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.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/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 6ec2e769a3..002cd04e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -215,7 +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.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 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 3b13dcc4ef..75e89017b0 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 @@ -114,7 +114,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupNewThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupsHomeScreen 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 @@ -377,7 +376,7 @@ fun BuildNavigation( composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { PublicChatsScreen(accountViewModel, nav) } - composableFromEnd { RelayGroupsHomeScreen(accountViewModel, nav) } + composableFromEnd { RelayGroupDiscoveryScreen(accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } @@ -637,13 +636,6 @@ fun BuildNavigation( ) } - composableFromEndArgs { - RelayGroupDiscoveryScreen( - accountViewModel = accountViewModel, - nav = nav, - ) - } - composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } 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 9903c88929..b6301cffc7 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 @@ -679,8 +679,6 @@ sealed class Route { @Serializable object RelayGroupBrowse : Route() - @Serializable object RelayGroupDiscovery : Route() - @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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 8579921869..5135d3dda3 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 @@ -337,8 +337,8 @@ class TopNavFilterState( emit( listOf( // Relay-group discovery routes to relays by author, hashtag and geohash, plus a - // favorite-relay chip; mirrors the kind3 catalog like the git-repositories feed. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + // favorite-relay chip; "Mine" is the joined-groups view (the tab's default). + listOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow), peopleLists, interests, listOf(muteListFollow), @@ -447,7 +447,7 @@ class TopNavFilterState( val relayGroupsDiscoveryRoutes = _relayGroupsDiscoveryRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)) val podcastRoutes = _podcastRoutes 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 index c2c6b3c1fd..20fafd1a25 100644 --- 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 @@ -33,10 +33,10 @@ 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.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton 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.runtime.Composable @@ -64,14 +64,19 @@ 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.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.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon 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.datasource.RelayGroupPreviewSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription 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 @@ -106,35 +111,19 @@ fun RelayGroupDiscoveryScreen( 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). + RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) - Scaffold( - topBar = { - val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList - .collectAsStateWithLifecycle() - val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes - .collectAsStateWithLifecycle() - TopBarExtensibleWithBackButton( - title = { - FeedFilterSpinner( - placeholderCode = selectedFilter, - explainer = stringRes(R.string.select_list_to_filter), - options = options, - onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList, - accountViewModel = accountViewModel, - ) - }, - actions = { - IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) { - Icon( - symbol = MaterialSymbols.Link, - contentDescription = stringRes(R.string.relay_group_browse_title), - modifier = Modifier.size(22.dp), - ) - } - }, - popBack = nav::popBack, - ) + DisappearingScaffold( + isInvertedLayout = false, + topBar = { RelayGroupsDiscoveryTopBar(accountViewModel, nav) }, + bottomBar = { + AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route -> + if (route == Route.RelayGroups) feedContentState.sendToTop() else nav.navBottomBar(route) + } }, + accountViewModel = accountViewModel, ) { padding -> Column(Modifier.padding(padding)) { RefresheableBox(feedContentState, true) { @@ -168,6 +157,51 @@ fun RelayGroupDiscoveryScreen( } } +/** + * Root top bar for the Relay Groups tab: the drawer opener (or back arrow on a sub-stack), the + * feed-filter spinner as title, and a browse-a-relay-by-URL action. Mirrors GitRepositoriesTopBar + * but keeps the browse action, which the shared UserDrawerSearchTopBar doesn't expose. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RelayGroupsDiscoveryTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList + .collectAsStateWithLifecycle() + val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes + .collectAsStateWithLifecycle() + + ShorterTopAppBar( + title = { + FeedFilterSpinner( + placeholderCode = selectedFilter, + explainer = stringRes(R.string.select_list_to_filter), + options = options, + onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList, + accountViewModel = accountViewModel, + ) + }, + navigationIcon = { + if (nav.canPop()) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } else { + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + } + }, + actions = { + IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) { + Icon( + symbol = MaterialSymbols.Link, + contentDescription = stringRes(R.string.relay_group_browse_title), + modifier = Modifier.size(22.dp), + ) + } + }, + ) +} + @Composable private fun WatchAccountForRelayGroupDiscovery( feedContentState: FeedContentState, 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 index 6670409fc3..7270680b71 100644 --- 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 @@ -71,8 +71,8 @@ fun RelayGroupServerList( } // Always offer a discovery entry, so a user with no groups yet still has - // somewhere to start. - FindChannelsRow { nav.nav(Route.RelayGroupDiscovery) } + // somewhere to start — the Relay Groups tab is the discovery feed. + FindChannelsRow { nav.nav(Route.RelayGroups) } HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt deleted file mode 100644 index 315e022816..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route -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.datasource.RelayGroupRosterSubscription -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * The top-level "Relay Groups" destination — one tap from the nav drawer / bottom - * bar, distinct from the DM-centric Messages tab. Lists the host relays of the - * groups the user has joined (each drilling into that relay's channels) and offers - * the discovery entry to find more. Keeps every joined group's roster live while - * on top via [RelayGroupRosterSubscription]. - */ -@Composable -fun RelayGroupsHomeScreen( - accountViewModel: AccountViewModel, - nav: INav, -) { - RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) - - DisappearingScaffold( - isInvertedLayout = false, - topBar = { - UserDrawerSearchTopBar(accountViewModel, nav) { - Text( - text = stringRes(R.string.relay_groups_title), - fontWeight = FontWeight.Bold, - ) - } - }, - bottomBar = { - AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route -> - nav.navBottomBar(route) - } - }, - accountViewModel = accountViewModel, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), - ) { - // Joined relays (each drills into its channels), followed by the - // always-present "Find groups" discovery row. - RelayGroupServerList(accountViewModel, nav) - } - } -} 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 index 79d02b87f1..4d74dc3beb 100644 --- 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 @@ -64,9 +64,18 @@ class RelayGroupDiscoveryFeedFilter( fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value + /** + * "My Groups" = the groups I've joined. These live on their host relays (from kind 10009), not + * my outbox, so the standard [TopFilter.Mine] relay-set resolution wouldn't reach them — instead + * we scan the cache for groups where I'm the relay-key / an admin / a member. The joined groups' + * metadata + rosters are kept in cache by RelayGroupRosterSubscription mounted on the screen. + */ + private fun isMine(): Boolean = followList() is TopFilter.Mine + 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 -> @@ -75,6 +84,13 @@ class RelayGroupDiscoveryFeedFilter( return sort(notes) } + private fun myGroupNotes(): Set { + val me = account.userProfile().pubkeyHex + return LocalCache.relayGroupChannels + .filter { _, channel -> channel.event != null && channel.membershipOf(me).isMember() } + .mapNotNullTo(HashSet()) { it.metadataNote } + } + override fun applyFilter(newItems: Set): Set { val byRelay = constraints() return newItems.flatMapTo(HashSet()) { note -> @@ -100,8 +116,9 @@ class RelayGroupDiscoveryFeedFilter( note: Note, byRelay: Map, ): Boolean { - if (byRelay.isEmpty()) return false val channel = relayGroupDiscoveryChannelFor(note) ?: return false + if (isMine()) return channel.membershipOf(account.userProfile().pubkeyHex).isMember() + if (byRelay.isEmpty()) return false return byRelay[channel.groupId.relayUrl]?.matches(channel) == true } From 34bbb7b727a6936e2c4cdf99a695b4fcfb12b3c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:47:04 +0000 Subject: [PATCH 43/81] fix: robohash crash on short ids + standard Relay Groups top bar/layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RobohashAssembler read up to hash[10] but only required the input to be >10 chars; a 16-char NIP-29 group id is valid hex that decodes to 8 bytes, so hash[8] threw ArrayIndexOutOfBoundsException and crashed the discovery feed. Require >=22 hex chars (>=11 bytes) before decoding; anything shorter falls back to sha256 (32 bytes). Latent crash for any short hex seed. - The discovery screen used a hand-rolled ShorterTopAppBar (dropping the standard search icon + memory chip) to fit a browse action. Switched to the shared UserDrawerSearchTopBar like every other top-level feed; the browse-a-relay action moved to the FAB (DisappearingScaffold.floatingButton). - Removed the extra Column(Modifier.padding(padding)) wrapper that double- applied the top-bar inset (rememberFeedContentPadding already accounts for it) — that was the large blank block above the first card. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupDiscoveryScreen.kt | 123 ++++++++---------- .../commons/robohash/RobohashAssembler.kt | 5 +- 2 files changed, 61 insertions(+), 67 deletions(-) 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 index 20fafd1a25..1a995a739a 100644 --- 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 @@ -33,8 +33,8 @@ 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.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -66,13 +66,12 @@ 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.LoggedInUserPictureDrawer -import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +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.dal.relayGroupDiscoveryChannelFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewSubscription @@ -123,83 +122,75 @@ fun RelayGroupDiscoveryScreen( if (route == Route.RelayGroups) feedContentState.sendToTop() else nav.navBottomBar(route) } }, - accountViewModel = accountViewModel, - ) { padding -> - Column(Modifier.padding(padding)) { - 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() - 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(), accountViewModel, nav) - } - } - }, + 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() + 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(), accountViewModel, nav) + } + } + }, + ) + } } } } /** - * Root top bar for the Relay Groups tab: the drawer opener (or back arrow on a sub-stack), the - * feed-filter spinner as title, and a browse-a-relay-by-URL action. Mirrors GitRepositoriesTopBar - * but keeps the browse action, which the shared UserDrawerSearchTopBar doesn't expose. + * 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. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RelayGroupsDiscoveryTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList - .collectAsStateWithLifecycle() - val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes - .collectAsStateWithLifecycle() + UserDrawerSearchTopBar(accountViewModel, nav) { + val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList + .collectAsStateWithLifecycle() + val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes + .collectAsStateWithLifecycle() - ShorterTopAppBar( - title = { - FeedFilterSpinner( - placeholderCode = selectedFilter, - explainer = stringRes(R.string.select_list_to_filter), - options = options, - onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList, - accountViewModel = accountViewModel, - ) - }, - navigationIcon = { - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, - actions = { - IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) { - Icon( - symbol = MaterialSymbols.Link, - contentDescription = stringRes(R.string.relay_group_browse_title), - modifier = Modifier.size(22.dp), - ) - } - }, - ) + FeedFilterSpinner( + placeholderCode = selectedFilter, + explainer = stringRes(R.string.select_list_to_filter), + options = options, + onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList, + accountViewModel = accountViewModel, + ) + } } @Composable 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" } From a44007bb95eb15e5a5f1ba7b8f127c0173b66c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:54:23 +0000 Subject: [PATCH 44/81] fix: order the "Mine" chip last in the Relay Groups filter, like other feeds The joined-groups ("Mine") entry was placed first in the discovery filter dropdown; every other feed lists it last in the base group (after Global). Match that ordering so the top-nav popup is consistent across screens. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 5135d3dda3..53e54b2b8b 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 @@ -337,8 +337,9 @@ class TopNavFilterState( emit( listOf( // Relay-group discovery routes to relays by author, hashtag and geohash, plus a - // favorite-relay chip; "Mine" is the joined-groups view (the tab's default). - listOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + // favorite-relay chip; "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, listOf(muteListFollow), @@ -447,7 +448,7 @@ class TopNavFilterState( val relayGroupsDiscoveryRoutes = _relayGroupsDiscoveryRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) val podcastRoutes = _podcastRoutes From ec3eff83d8ad1603626cab1dc49ee68fb30fe959 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:58:49 +0000 Subject: [PATCH 45/81] =?UTF-8?q?fix:=20"My=20Groups"=20shows=20joined=20l?= =?UTF-8?q?ist=20=E2=88=AA=20roster=20memberships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Mine" only listed groups where the relay-signed roster (39001/39002) already had me as an admin/member — so a group I just joined (or an open group I post in without being rostered) wouldn't appear until the relay caught up. It now shows the UNION of: - my kind-10009 joined list (authoritative, immediate), and - groups whose roster lists me as an admin/member. The screen re-scans when the joined list changes (join/leave), so a newly joined group appears right away. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupDiscoveryScreen.kt | 6 ++- .../dal/RelayGroupDiscoveryFeedFilter.kt | 38 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) 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 index 1a995a739a..293b983f78 100644 --- 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 @@ -202,8 +202,12 @@ private fun WatchAccountForRelayGroupDiscovery( .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() - LaunchedEffect(listName, perRelay) { + LaunchedEffect(listName, perRelay, joinedGroups) { feedContentState.checkKeysInvalidateDataAndSendToTop() } } 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 index 4d74dc3beb..ff6d8b4a64 100644 --- 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 @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.filterIntoSet 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 @@ -65,13 +66,30 @@ class RelayGroupDiscoveryFeedFilter( fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value /** - * "My Groups" = the groups I've joined. These live on their host relays (from kind 10009), not - * my outbox, so the standard [TopFilter.Mine] relay-set resolution wouldn't reach them — instead - * we scan the cache for groups where I'm the relay-key / an admin / a member. The joined groups' - * metadata + rosters are kept in cache by RelayGroupRosterSubscription mounted on the screen. + * "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 RelayGroupRosterSubscription 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 { @@ -85,10 +103,16 @@ class RelayGroupDiscoveryFeedFilter( } 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 - return LocalCache.relayGroupChannels + LocalCache.relayGroupChannels .filter { _, channel -> channel.event != null && channel.membershipOf(me).isMember() } - .mapNotNullTo(HashSet()) { it.metadataNote } + .forEach { it.metadataNote?.let(notes::add) } + return notes } override fun applyFilter(newItems: Set): Set { @@ -117,7 +141,7 @@ class RelayGroupDiscoveryFeedFilter( byRelay: Map, ): Boolean { val channel = relayGroupDiscoveryChannelFor(note) ?: return false - if (isMine()) return channel.membershipOf(account.userProfile().pubkeyHex).isMember() + if (isMine()) return isMyGroup(channel, joinedGroupIds()) if (byRelay.isEmpty()) return false return byRelay[channel.groupId.relayUrl]?.matches(channel) == true } From a4679da0a9b55e111ece3911177531988d1fdb44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 15:25:03 +0000 Subject: [PATCH 46/81] refactor: move GroupDiscoveryConstraint matcher to commons The sealed GroupDiscoveryConstraint matcher (AllGroups/ByPeople/ByHashtags/ ByGeohashes/AnyOf) is pure over RelayGroupChannel + HexKey with no LocalCache, eose-manager, or topNavFeeds dependency, so it belongs in :commons alongside RelayGroupChannel where Desktop/CLI can reuse it. The amethyst dal keeps only the platform-specific toGroupConstraints() mapping from the Android top-nav filter set onto the shared matcher. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../dal/GroupDiscoveryConstraint.kt | 81 ++--------------- .../dal/RelayGroupDiscoveryFeedFilter.kt | 1 + .../dal/RelayGroupDiscoveryConstraintTest.kt | 1 + .../GroupDiscoveryConstraint.kt | 91 +++++++++++++++++++ 4 files changed, 101 insertions(+), 73 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/GroupDiscoveryConstraint.kt 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 index a819feadba..8b67b05bf0 100644 --- 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 @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +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 @@ -31,81 +31,16 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN 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.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** - * The per-type match a discovery filter applies to a [RelayGroupChannel] on ONE relay. - * - * A NIP-29 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 just 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]). - */ -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) } - } -} - -/** - * Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. 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. + * 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) { 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 index ff6d8b4a64..34cc9c85d0 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 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 index 5a6c8910b1..09dd71b292 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 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) } + } +} From 4265d7a00b3acab8cbd9223c78c603cb0fa13ac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 15:30:51 +0000 Subject: [PATCH 47/81] feat: consume the remaining NIP-29 event kinds in LocalCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only 39000/39001/39002 (metadata + rosters) and the group-scoped content kinds were consumed; every other registered NIP-29 kind deserialized fine but fell through to the "Event Not Supported" else branch and was dropped. Add explicit branches for the whole family: - 39003 SupportedRoles and 39004 GroupParticipants are relay-signed addressables — durable group state alongside 39000/1/2 — so they're stored replaceably (consumeBaseReplaceable). - the 9xxx moderation actions (put/remove user, edit/delete metadata, create/ delete group, create invite) and 9021/9022 join/leave requests are regular one-shot events the relay is authoritative for; store them via consumeRegularEvent so they're queryable and no longer warn, without acting on them client-side. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/model/LocalCache.kt | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) 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 922383eb08..0d620309c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -158,6 +158,17 @@ 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 @@ -3690,6 +3701,56 @@ object LocalCache : ILocalCache, ICacheProvider { 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) } From 3529a71052add1f48afc017b52aca8752e5058f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 16:22:36 +0000 Subject: [PATCH 48/81] fix: send a discovery REQ when a relay chip is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery filter spinner offers a favorite-relay chip (TopFilter.Relay), but makeRelayGroupsDiscoveryFilter had no branch for RelayTopNavPerRelayFilterSet — it fell through to `else -> emptyList()`, so selecting a relay sent no REQ and the feed stayed empty even though the dal's toGroupConstraints() already mapped that filter to AllGroups-on-that-relay. Add filterRelayGroupsByRelay (the same whole-directory pull as Global, scoped to the one relay) and wire the dispatch branch, restoring parity between the two per-type tables. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../datasource/RelayGroupsDiscoveryFilter.kt | 3 ++ .../subassemblies/FilterRelayGroupsByRelay.kt | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt 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 index 6118c05708..ca4990de21 100644 --- 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 @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.All 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 @@ -37,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG 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 @@ -53,6 +55,7 @@ fun makeRelayGroupsDiscoveryFilter( 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/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, + ) From 6cca872f801bf20794898c064e954914d25e87da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 16:38:17 +0000 Subject: [PATCH 49/81] fix: refresh the groups feed when the resolved relay set catches up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flipping the top-nav filter A->B showed A's groups until a manual pull-to- refresh. The selected list flips synchronously, but the per-relay set the feed filters on (liveRelayGroupsDiscoveryFollowListsPerRelay) resolves a frame later via the async outbox loader. feedKey() keyed only on the list code, so the first refresh ran against the stale (A) set and the catch-up emission — same list code — was swallowed by checkKeysInvalidateDataAndSendToTop's key-unchanged guard, freezing the feed on A. Fold the resolved discriminator into feedKey(): the joined ids for "My Groups", the per-relay constraints otherwise (both content-hashed via data classes). The key now moves when the resolution lands, so the refresh fires and the feed follows the selection without a manual pull. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../dal/RelayGroupDiscoveryFeedFilter.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 index 34cc9c85d0..ad3c96872e 100644 --- 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 @@ -60,7 +60,20 @@ fun relayGroupDiscoveryChannelFor(note: Note): RelayGroupChannel? { class RelayGroupDiscoveryFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + /** + * 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 From cc048c7ad1281bf61154f28cd70d92231775f659 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 16:42:40 +0000 Subject: [PATCH 50/81] feat: show the host relay as a starred chip on group discovery cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone star icon sat right next to the group's Join button, so it read as a second way to act on the group when it actually favorites the host RELAY. Pull the relay out of the member-count line into its own tappable chip with the star inside it — favorited relays fill primary, unfavorited show a tonal outline — so the two scopes are visually distinct: the chip favorites the relay (and surfaces its groups under the relay filter), the button joins the group. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupDiscoveryScreen.kt | 109 +++++++++++++----- 1 file changed, 77 insertions(+), 32 deletions(-) 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 index 293b983f78..f94f2892a5 100644 --- 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 @@ -35,7 +35,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -79,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl /** @@ -308,11 +308,11 @@ private fun RelayGroupDiscoveryCard( ) DiscoveryStatusPill(channel) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (memberCount > 0) { + if (memberCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { Icon( symbol = MaterialSymbols.Group, contentDescription = null, @@ -324,36 +324,10 @@ private fun RelayGroupDiscoveryCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text( - text = "·", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } - Text( - text = relay.displayUrl(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) } } - // Star the group's host relay so it (and its other groups) surface under the relay - // chip in the filter — the favorite is the kind-10012 relay-feeds list. - IconButton(onClick = { - if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay) - }) { - Icon( - symbol = if (isFavoriteRelay) MaterialSymbols.Star else MaterialSymbols.StarBorder, - contentDescription = stringRes(R.string.relay_group_favorite_relay), - tint = if (isFavoriteRelay) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } - if (!isMember) { FilledTonalButton(onClick = { // Open groups join directly; closed ones open the group where the invite @@ -369,6 +343,18 @@ private fun RelayGroupDiscoveryCard( } } + // The host relay as its own chip, with the star inside it — makes explicit that the + // star favorites the RELAY (surfacing all its groups under the relay filter), not the + // group. Joining the group is the separate button above. + RelayFavoriteChip( + relay = relay, + isFavorite = isFavoriteRelay, + onToggle = { + if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay) + }, + modifier = Modifier.padding(top = 8.dp), + ) + channel.summary()?.takeIf { it.isNotBlank() }?.let { Text( text = it, @@ -383,6 +369,65 @@ private fun RelayGroupDiscoveryCard( } } +/** + * The group's host relay rendered as a tappable chip with the favorite star inside it. Tapping + * 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 two scopes (favorite the + * relay vs. join the group) don't read as the same action. + */ +@Composable +private fun RelayFavoriteChip( + relay: NormalizedRelayUrl, + isFavorite: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier.fillMaxWidth()) { + Surface( + onClick = onToggle, + 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(4.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.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.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, + ) + } + } + } +} + /** Small tonal pill: Invite-only wins over Private, matching the inline group card. */ @Composable private fun DiscoveryStatusPill(channel: RelayGroupChannel) { From a545151a343d50d2ce7bc9b49155b77ffbea3212 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 17:24:03 +0000 Subject: [PATCH 51/81] fix: reject non-32-byte pubkeys when decoding npub/nprofile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Nostr pubkey is x-only, exactly 32 bytes, but NPub.parse/NProfile.parse never checked the length — they hex-encoded whatever bytes the bech32/TLV carried. A malformed npub/nprofile that some clients encode with the full 33-byte COMPRESSED secp256k1 key (0x02/0x03 prefix) therefore round-tripped its 66-char hex straight into a `p`/`q` tag via the quote/mention path, and a strict relay (relay29 / pyramid.fiatjaf.com) rejected the whole group message: blocked: schema validation failed: tag[..]: invalid pubkey value '02977dcf…c3402' ... pubkey should be 64-char hex We never generate compressed keys ourselves (Nip01Crypto.pubKeyCreate strips the prefix byte); this is purely inbound malformed input. Enforce the 32-byte length at the decode boundary so the bad entity never becomes a mention/quote tag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../quartz/nip19Bech32/entities/NProfile.kt | 4 +++- .../quartz/nip19Bech32/entities/NPub.kt | 6 ++++- .../quartz/nip19Bech32/NIP19ParserTest.kt | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) 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/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) + } } From c253a5aa1247a041b33449430800f3252c7eb961 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 17:45:55 +0000 Subject: [PATCH 52/81] feat: relay autocomplete on the Find Groups browse field The browse-a-relay field was a plain text box. Wire it to the same relay autocomplete every other relay field uses (RelaySuggestionState + ShowRelaySuggestionList over LocalCache.relayHints): as you type, a popup lists matching known relays, and tapping one opens that relay's group directory directly. The manual paste-and-Go path and the your-relays / popular sections are unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupBrowseScreen.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 index 2d87e85c63..fee8bfbe3e 100644 --- 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 @@ -28,6 +28,8 @@ 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 @@ -45,12 +47,17 @@ 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 /** @@ -78,6 +85,11 @@ fun RelayGroupBrowseScreen( 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() @@ -87,6 +99,7 @@ fun RelayGroupBrowseScreen( showError = true return } + relaySuggestions.reset() nav.nav(Route.RelayGroupServer(normalized.url)) } @@ -123,6 +136,7 @@ fun RelayGroupBrowseScreen( onValueChange = { relayUrl = it showError = false + relaySuggestions.processInput(it) }, singleLine = true, isError = showError, @@ -139,6 +153,24 @@ fun RelayGroupBrowseScreen( 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(), From 4fca9ae25ebbe165c16479ec3e514998c1946645 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 18:24:36 +0000 Subject: [PATCH 53/81] feat: message-count + follows-in-group on discovery cards; split relay chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three group-discovery card changes: - Reactive loaded-message count. The preview subscription streams the group's recent kind-9 chats into the channel note cache; the card now shows that count on the stats line ("12 members · 50+ messages"), updating live as messages arrive. Bumped the preview page 15 -> 50 so an active chat reads as "50+" (also a better warm-up); the display caps at DISCOVERY_MESSAGE_CAP. - People-you-follow social proof. RelayGroupChannel.participatingFollows() intersects the relay-signed roster with the kind-3 follow set; the card shows an overlapping face pile + "%d people you follow" caption when non-empty. - Split the relay chip's tap targets. Tapping the chip body now opens that relay's full group list (Route.RelayGroupServer); only the star toggles the relay favorite. Previously the whole chip favorited, which was easy to hit by accident. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupDiscoveryScreen.kt | 169 +++++++++++++++--- .../RelayGroupPreviewFilterAssembler.kt | 8 +- amethyst/src/main/res/values/strings.xml | 12 ++ .../nip29RelayGroups/RelayGroupChannel.kt | 9 + 4 files changed, 167 insertions(+), 31 deletions(-) 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 index f94f2892a5..988c2bdd94 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -71,6 +72,7 @@ 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.datasource.RelayGroupPreviewSubscription @@ -78,6 +80,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG 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.displayUrl @@ -147,6 +151,10 @@ fun RelayGroupDiscoveryScreen( 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(), @@ -157,7 +165,7 @@ fun RelayGroupDiscoveryScreen( key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> - RelayGroupDiscoveryRow(item, Modifier.animateItem(), accountViewModel, nav) + RelayGroupDiscoveryRow(item, Modifier.animateItem(), follows.authors, accountViewModel, nav) } } }, @@ -223,6 +231,7 @@ private fun WatchAccountForRelayGroupDiscovery( private fun RelayGroupDiscoveryRow( note: Note, modifier: Modifier, + follows: Set, accountViewModel: AccountViewModel, nav: INav, ) { @@ -241,6 +250,19 @@ private fun RelayGroupDiscoveryRow( .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() @@ -249,6 +271,8 @@ private fun RelayGroupDiscoveryRow( modifier = modifier, myPubkey = accountViewModel.userProfile().pubkeyHex, isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays, + messageCount = messageCount, + participatingFollows = participatingFollows, accountViewModel = accountViewModel, nav = nav, ) @@ -260,6 +284,8 @@ private fun RelayGroupDiscoveryCard( modifier: Modifier, myPubkey: String, isFavoriteRelay: Boolean, + messageCount: Int, + participatingFollows: List, accountViewModel: AccountViewModel, nav: INav, ) { @@ -308,22 +334,45 @@ private fun RelayGroupDiscoveryCard( ) DiscoveryStatusPill(channel) } - if (memberCount > 0) { + if (memberCount > 0 || messageCount > 0) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - 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) { + 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, + ) + } } } } @@ -343,15 +392,26 @@ private fun RelayGroupDiscoveryCard( } } - // The host relay as its own chip, with the star inside it — makes explicit that the - // star favorites the RELAY (surfacing all its groups under the relay filter), not the - // group. Joining the group is the separate button above. - RelayFavoriteChip( + // 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, - onToggle = { + onToggleFavorite = { if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay) }, + onOpenRelay = { nav.nav(Route.RelayGroupServer(relay.url)) }, modifier = Modifier.padding(top = 8.dp), ) @@ -370,22 +430,23 @@ private fun RelayGroupDiscoveryCard( } /** - * The group's host relay rendered as a tappable chip with the favorite star inside it. Tapping - * 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 two scopes (favorite the - * relay vs. join the group) don't read as the same action. + * 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 RelayFavoriteChip( +private fun RelayChip( relay: NormalizedRelayUrl, isFavorite: Boolean, - onToggle: () -> Unit, + onToggleFavorite: () -> Unit, + onOpenRelay: () -> Unit, modifier: Modifier = Modifier, ) { Row(modifier = modifier.fillMaxWidth()) { Surface( - onClick = onToggle, + onClick = onOpenRelay, shape = RoundedCornerShape(8.dp), color = if (isFavorite) { @@ -397,8 +458,8 @@ private fun RelayFavoriteChip( ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + 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, @@ -409,7 +470,12 @@ private fun RelayFavoriteChip( } else { MaterialTheme.colorScheme.onSurfaceVariant }, - modifier = Modifier.size(16.dp), + modifier = + Modifier + .clip(CircleShape) + .clickable(onClick = onToggleFavorite) + .padding(4.dp) + .size(16.dp), ) Text( text = relay.displayUrl(), @@ -428,6 +494,51 @@ private fun RelayFavoriteChip( } } +/** + * 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_PREVIEW_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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt index dbe3f0c048..2d816e5c03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt @@ -46,8 +46,12 @@ class RelayGroupPreviewQueryState( private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND) -/** How many recent events to pull ahead of a tap — enough to fill the first screen, no more. */ -private const val RELAY_GROUP_PREVIEW_LIMIT = 15 +/** + * How many 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). + */ +private const val RELAY_GROUP_PREVIEW_LIMIT = 50 /** * Warms a NIP-29 group referenced inline (a group-link card) without opening it: diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fc48c5f138..b9826e1bcf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2011,6 +2011,18 @@ %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/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 index 1e479af558..d3464474d3 100644 --- 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 @@ -169,6 +169,15 @@ class RelayGroupChannel( /** 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 From 2527364a61b3a571ae79f10fe203bed8f1348fca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 19:08:27 +0000 Subject: [PATCH 54/81] fix: load the kind-10009 groups list at login (consume + REQ) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's NIP-51 "simple groups" list (kind 10009 — joined NIP-29 groups + servers) was neither consumed nor requested at login: - LocalCache had no dispatch branch for SimpleGroupListEvent, so an arriving 10009 fell through to the "Event Not Supported" else and was dropped — RelayGroupListState, which reads it from the addressable cache, could never populate from the network (only from the on-device offline backup). - The account-info assemblers (filterAccountInfoAndListsFromKey / filterBasicAccountInfoFromKeys) never REQd kind 10009 alongside the sibling NIP-51 lists, so a fresh sign-in never fetched it. Add the consume branch (consumeBaseReplaceable, like every sibling list) and include SimpleGroupListEvent.KIND in both assemblers, so "My Groups" and group memberships resolve from the start of login without opening the groups screen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 8 ++++++++ .../account/metadata/FilterAccountInfoAndListsFromKey.kt | 5 +++++ .../account/metadata/FilterBasicAccountInfoFromKeys.kt | 3 +++ 3 files changed, 16 insertions(+) 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 0d620309c7..f4399cc130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -212,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 @@ -3689,6 +3690,13 @@ 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) } 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( From 525a615b51bd43bfb7a454659ddcae4ef1311fa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 19:08:42 +0000 Subject: [PATCH 55/81] fix: circular FAB on the relay group list + threads screens The "groups on this relay" and group-threads screens used a bare FloatingActionButton, which renders as Material 3's default rounded-square shape. Every other new-post FAB in the app is circular (shape = CircleShape); match it so the group FABs read the same as the rest of the app. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../publicChannels/relayGroup/RelayGroupChannelListScreen.kt | 2 +- .../chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 index feeb2d25b8..0d148cf599 100644 --- 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 @@ -113,7 +113,7 @@ fun RelayGroupChannelListScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }) { + FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { Icon( symbol = MaterialSymbols.Add, contentDescription = stringRes(R.string.relay_group_create_title), 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 index 756629d8ed..210bb22403 100644 --- 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 @@ -31,6 +31,7 @@ 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 @@ -127,6 +128,7 @@ private fun RelayGroupThreads( if (canPost) { FloatingActionButton( onClick = { nav.nav(Route.RelayGroupNewThread(channel.groupId.id, channel.groupId.relayUrl.url)) }, + shape = CircleShape, ) { Icon( symbol = MaterialSymbols.Add, From 23c93e43f94b9f6b33f658304443bc981d4ddab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 19:14:14 +0000 Subject: [PATCH 56/81] feat: gate the group chat composer on membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-29 relays reject kind-9 writes from non-members, so typing in a group you haven't joined only earns a silent relay rejection. Show the composer only when the relay-signed roster (39001/39002) lists me as a member/mod/admin — the same boundary the threads FAB already uses — collecting the channel metadata flow so it appears the instant my join is accepted. Otherwise replace it with a notice: "Join this group to send messages" (open) or an invite-only explanation (closed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupChannelView.kt | 62 +++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 2 + 2 files changed, 59 insertions(+), 5 deletions(-) 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 index 34b2833699..b84c48923e 100644 --- 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 @@ -23,13 +23,22 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay 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 @@ -40,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.Ch 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 @@ -141,11 +151,53 @@ private fun ChannelView( Spacer(modifier = DoubleVertSpacer) - EditFieldRow( - newPostModel, - accountViewModel, - onSendNewMessage = feedViewModel.feedState::sendToTop, - nav, + // 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b9826e1bcf..812814c060 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2007,6 +2007,8 @@ Browse Relays you\'re on Popular relays + Join this group to send messages. + This group is invite-only — you need an invite to post. %1$d member %1$d members From 2ffd64c1706845461c3454e6e0c2b109b2c70327 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 20:05:43 +0000 Subject: [PATCH 57/81] refactor: rename relay-group REQ assemblers by when they run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembler names didn't say when each is active — most confusingly, a "Threads" assembler with no matching "chat" one, because group chat (kind-9) is served by the shared `channel` assembler, not a group-specific one. Rename the four group-specific families for their surface, and document that chat has no dedicated assembler: relayGroupDirectory -> relayGroupsOnRelay (browsing one relay's channels) relayGroupRoster -> relayGroupMyJoinedGroups (metadata+rosters of joined groups) relayGroupThreads -> relayGroupThreadFeed (a group's forum-threads tab) relayGroupPreview -> relayGroupWarmup (prefetch before a group opens) Each family's FilterAssembler / QueryState / SubAssembler / Subscription + file renamed to match. relayGroupsDiscovery is left as-is: it already names the Discover feed and shares its token namespace with the screen/DAL/settings, so a rename would either collide with RelayGroupDiscoveryFeedFilter or corrupt those. Pure rename; no behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../RelaySubscriptionsCoordinator.kt | 31 +++++++++++-------- .../amethyst/ui/components/RelayGroupCard.kt | 4 +-- .../loggedIn/BottomBarFeedPreloaders.kt | 4 +-- .../relayGroup/RelayGroupChannelListScreen.kt | 6 ++-- .../relayGroup/RelayGroupDiscoveryScreen.kt | 10 +++--- .../relayGroup/RelayGroupMembersScreen.kt | 4 +-- .../relayGroup/RelayGroupMetadataScreen.kt | 4 +-- .../relayGroup/RelayGroupThreadsScreen.kt | 6 ++-- .../dal/RelayGroupDiscoveryFeedFilter.kt | 2 +- ...elayGroupMyJoinedGroupsFilterAssembler.kt} | 18 +++++------ ...> RelayGroupMyJoinedGroupsSubscription.kt} | 6 ++-- ...=> RelayGroupThreadFeedFilterAssembler.kt} | 18 +++++------ ...kt => RelayGroupThreadFeedSubscription.kt} | 6 ++-- ....kt => RelayGroupWarmupFilterAssembler.kt} | 26 ++++++++-------- ...ion.kt => RelayGroupWarmupSubscription.kt} | 6 ++-- ...t => RelayGroupsOnRelayFilterAssembler.kt} | 18 +++++------ ...n.kt => RelayGroupsOnRelaySubscription.kt} | 6 ++-- .../rooms/singlepane/MessagesSinglePane.kt | 4 +-- .../chats/rooms/twopane/MessagesTwoPane.kt | 4 +-- 19 files changed, 94 insertions(+), 89 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupRosterFilterAssembler.kt => RelayGroupMyJoinedGroupsFilterAssembler.kt} (88%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupRosterSubscription.kt => RelayGroupMyJoinedGroupsSubscription.kt} (93%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupThreadsFilterAssembler.kt => RelayGroupThreadFeedFilterAssembler.kt} (86%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupThreadsSubscription.kt => RelayGroupThreadFeedSubscription.kt} (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupPreviewFilterAssembler.kt => RelayGroupWarmupFilterAssembler.kt} (85%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupPreviewSubscription.kt => RelayGroupWarmupSubscription.kt} (93%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupDirectoryFilterAssembler.kt => RelayGroupsOnRelayFilterAssembler.kt} (87%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/{RelayGroupDirectorySubscription.kt => RelayGroupsOnRelaySubscription.kt} (91%) 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 1f83d9daa8..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,11 +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.RelayGroupDirectoryFilterAssembler -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewFilterAssembler -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadsFilterAssembler +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 @@ -118,11 +118,16 @@ class RelaySubscriptionsCoordinator( // active depending on the screen. val channel = ChannelFilterAssembler(client) - val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client) - val relayGroupRoster = RelayGroupRosterFilterAssembler(client) - val relayGroupThreads = RelayGroupThreadsFilterAssembler(client) - val relayGroupPreview = RelayGroupPreviewFilterAssembler(client) - val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(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) @@ -184,10 +189,10 @@ class RelaySubscriptionsCoordinator( val all = listOf( - relayGroupDirectory, - relayGroupRoster, - relayGroupThreads, - relayGroupPreview, + relayGroupsOnRelay, + relayGroupMyJoinedGroups, + relayGroupThreadFeed, + relayGroupWarmup, relayGroupsDiscovery, account, accountForeground, 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 index 371939fece..96e3bf48ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -53,7 +53,7 @@ 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.RelayGroupPreviewSubscription +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 @@ -93,7 +93,7 @@ private fun RelayGroupCardContent( accountViewModel: AccountViewModel, nav: INav, ) { - RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel) + RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) // Recompose in place when the relay-signed metadata / roster changes. val channelState by baseChannel 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 bc5becb547..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,7 +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.RelayGroupRosterSubscription +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 @@ -132,7 +132,7 @@ private fun PreloadFor( NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel) - NavBarItem.RELAY_GROUPS -> RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) + NavBarItem.RELAY_GROUPS -> RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) 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 index 0d148cf599..62abf89d7a 100644 --- 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 @@ -60,7 +60,7 @@ 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.RelayGroupDirectorySubscription +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 @@ -70,7 +70,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent /** * 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 - * [RelayGroupDirectorySubscription] and consumed into per-group channels; this + * [RelayGroupsOnRelaySubscription] and consumed into per-group channels; this * screen reads them back for the relay and renders them. */ @Composable @@ -81,7 +81,7 @@ fun RelayGroupChannelListScreen( ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return - RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel) + RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel) // 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 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 index 988c2bdd94..4c71a6190d 100644 --- 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 @@ -75,8 +75,8 @@ 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.datasource.RelayGroupPreviewSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription +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 @@ -116,7 +116,7 @@ fun RelayGroupDiscoveryScreen( 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). - RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) DisappearingScaffold( isInvertedLayout = false, @@ -242,7 +242,7 @@ private fun RelayGroupDiscoveryRow( // 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. - RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel, contentOnly = true) + RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel, contentOnly = true) val channelState by baseChannel .flow() @@ -535,7 +535,7 @@ private fun FollowsInGroupRow( /** * Display cap for the loaded-message counter — kept in step with the discovery preview's fetch - * limit (RELAY_GROUP_PREVIEW_LIMIT), so a chat that returns the full page reads as "50+ messages". + * limit (RELAY_GROUP_WARMUP_LIMIT), so a chat that returns the full page reads as "50+ messages". */ private const val DISCOVERY_MESSAGE_CAP = 50 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 index 186893dede..70b810d053 100644 --- 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 @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack 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.RelayGroupPreviewSubscription +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 @@ -107,7 +107,7 @@ private fun RelayGroupMembers( // 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. - RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel) + RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) // Recompose when the relay-signed roster (39001/39002) changes. val channelState by observeChannel(baseChannel, accountViewModel) 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 index ebf4e329da..b7b07d148e 100644 --- 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 @@ -69,7 +69,7 @@ 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.RelayGroupPreviewSubscription +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 @@ -115,7 +115,7 @@ fun RelayGroupEditScreen( LoadRelayGroupChannel(groupId, accountViewModel) { channel -> // Keep the relay-signed metadata fresh while editing so a late load prefills. - RelayGroupPreviewSubscription(channel, accountViewModel.dataSources().relayGroupPreview, accountViewModel) + RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) val channelState by channel .flow() 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 index 210bb22403..7eaf2d5f8b 100644 --- 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 @@ -58,7 +58,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack 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.RelayGroupThreadsSubscription +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 @@ -69,7 +69,7 @@ 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 - * [RelayGroupThreadsSubscription]; tapping a thread opens the generic thread view + * [RelayGroupThreadFeedSubscription]; tapping a thread opens the generic thread view * ([Route.Note]) with its comment tree. Members can start a new thread. */ @Composable @@ -93,7 +93,7 @@ private fun RelayGroupThreads( accountViewModel: AccountViewModel, nav: INav, ) { - RelayGroupThreadsSubscription(channel, accountViewModel.dataSources().relayGroupThreads, accountViewModel) + RelayGroupThreadFeedSubscription(channel, accountViewModel.dataSources().relayGroupThreadFeed, accountViewModel) val threads by channel.threads.collectAsStateWithLifecycle() 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 index ad3c96872e..0fa46660e3 100644 --- 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 @@ -88,7 +88,7 @@ class RelayGroupDiscoveryFeedFilter( * * 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 RelayGroupRosterSubscription mounted on the screen. + * rosters are kept in cache by RelayGroupMyJoinedGroupsSubscription mounted on the screen. */ private fun isMine(): Boolean = followList() is TopFilter.Mine diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterFilterAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt index 1195adf960..0db5b6bc3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsFilterAssembler.kt @@ -33,7 +33,7 @@ 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 RelayGroupRosterQueryState( +class RelayGroupMyJoinedGroupsQueryState( val account: Account, ) @@ -59,12 +59,12 @@ private val RELAY_GROUP_ROSTER_KINDS = * 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 RelayGroupRosterFilterAssembler( +class RelayGroupMyJoinedGroupsFilterAssembler( client: INostrClient, -) : ComposeSubscriptionManager() { +) : ComposeSubscriptionManager() { val group = listOf( - RelayGroupRosterSubAssembler(client, ::allKeys), + RelayGroupMyJoinedGroupsSubAssembler(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() @@ -74,12 +74,12 @@ class RelayGroupRosterFilterAssembler( override fun destroy() = group.forEach { it.destroy() } } -class RelayGroupRosterSubAssembler( +class RelayGroupMyJoinedGroupsSubAssembler( client: INostrClient, - allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( - key: RelayGroupRosterQueryState, + key: RelayGroupMyJoinedGroupsQueryState, since: SincePerRelayMap?, ): List? { val joined = key.account.relayGroupList.liveRelayGroupList.value @@ -102,5 +102,5 @@ class RelayGroupRosterSubAssembler( } } - override fun id(key: RelayGroupRosterQueryState) = key.account + override fun id(key: RelayGroupMyJoinedGroupsQueryState) = key.account } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsSubscription.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsSubscription.kt index c497a5151a..6206438d03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupRosterSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupMyJoinedGroupsSubscription.kt @@ -39,13 +39,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel * the new group's roster (critical for confirming admission to a closed group). */ @Composable -fun RelayGroupRosterSubscription( - dataSource: RelayGroupRosterFilterAssembler, +fun RelayGroupMyJoinedGroupsSubscription( + dataSource: RelayGroupMyJoinedGroupsFilterAssembler, accountViewModel: AccountViewModel, ) { val state = remember(accountViewModel.account) { - RelayGroupRosterQueryState(accountViewModel.account) + RelayGroupMyJoinedGroupsQueryState(accountViewModel.account) } val joined by accountViewModel.account.relayGroupList.liveRelayGroupList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedFilterAssembler.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedFilterAssembler.kt index c24145633e..0bdc00b095 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedFilterAssembler.kt @@ -32,7 +32,7 @@ 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 RelayGroupThreadsQueryState( +class RelayGroupThreadFeedQueryState( val channel: RelayGroupChannel, ) @@ -43,12 +43,12 @@ class RelayGroupThreadsQueryState( * 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 RelayGroupThreadsFilterAssembler( +class RelayGroupThreadFeedFilterAssembler( client: INostrClient, -) : ComposeSubscriptionManager() { +) : ComposeSubscriptionManager() { val group = listOf( - RelayGroupThreadsSubAssembler(client, ::allKeys), + RelayGroupThreadFeedSubAssembler(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() @@ -58,12 +58,12 @@ class RelayGroupThreadsFilterAssembler( override fun destroy() = group.forEach { it.destroy() } } -class RelayGroupThreadsSubAssembler( +class RelayGroupThreadFeedSubAssembler( client: INostrClient, - allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( - key: RelayGroupThreadsQueryState, + key: RelayGroupThreadFeedQueryState, since: SincePerRelayMap?, ): List { val groupId = key.channel.groupId @@ -80,5 +80,5 @@ class RelayGroupThreadsSubAssembler( ) } - override fun id(key: RelayGroupThreadsQueryState) = key.channel.groupId + 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/RelayGroupThreadsSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedSubscription.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedSubscription.kt index c462bc63bd..3272bc691d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadFeedSubscription.kt @@ -28,14 +28,14 @@ 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 RelayGroupThreadsSubscription( +fun RelayGroupThreadFeedSubscription( channel: RelayGroupChannel, - dataSource: RelayGroupThreadsFilterAssembler, + dataSource: RelayGroupThreadFeedFilterAssembler, accountViewModel: AccountViewModel, ) { val state = remember(channel.groupId) { - RelayGroupThreadsQueryState(channel) + RelayGroupThreadFeedQueryState(channel) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupFilterAssembler.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupFilterAssembler.kt index 2d816e5c03..fb26d7b829 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupFilterAssembler.kt @@ -36,14 +36,14 @@ 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 RelayGroupPreviewQueryState( +class RelayGroupWarmupQueryState( val channel: RelayGroupChannel, /** When true, prefetch only recent content — the caller's screen already streams metadata. */ val contentOnly: Boolean = false, ) /** Newest content kinds we prefetch so opening the card lands on populated screens. */ -private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = +private val RELAY_GROUP_WARMUP_CONTENT_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND) /** @@ -51,7 +51,7 @@ private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = * discovery card's "50+ messages" activity signal (a chat that returns the full page reads as * "50+"; fewer shows the exact loaded count). */ -private const val RELAY_GROUP_PREVIEW_LIMIT = 50 +private const val RELAY_GROUP_WARMUP_LIMIT = 50 /** * Warms a NIP-29 group referenced inline (a group-link card) without opening it: @@ -60,12 +60,12 @@ private const val RELAY_GROUP_PREVIEW_LIMIT = 50 * 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 RelayGroupPreviewFilterAssembler( +class RelayGroupWarmupFilterAssembler( client: INostrClient, -) : ComposeSubscriptionManager() { +) : ComposeSubscriptionManager() { val group = listOf( - RelayGroupPreviewSubAssembler(client, ::allKeys), + RelayGroupWarmupSubAssembler(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() @@ -75,12 +75,12 @@ class RelayGroupPreviewFilterAssembler( override fun destroy() = group.forEach { it.destroy() } } -class RelayGroupPreviewSubAssembler( +class RelayGroupWarmupSubAssembler( client: INostrClient, - allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( - key: RelayGroupPreviewQueryState, + key: RelayGroupWarmupQueryState, since: SincePerRelayMap?, ): List { val groupId = key.channel.groupId @@ -90,13 +90,13 @@ class RelayGroupPreviewSubAssembler( relay = groupId.relayUrl, filter = Filter( - kinds = RELAY_GROUP_PREVIEW_CONTENT_KINDS, + kinds = RELAY_GROUP_WARMUP_CONTENT_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), - limit = RELAY_GROUP_PREVIEW_LIMIT, + limit = RELAY_GROUP_WARMUP_LIMIT, since = since?.get(groupId.relayUrl)?.time, ), ) } - override fun id(key: RelayGroupPreviewQueryState) = key.channel.groupId + 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/RelayGroupPreviewSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupSubscription.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupSubscription.kt index b0bac400c7..86440fdae9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupWarmupSubscription.kt @@ -32,15 +32,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel * subscription already streams that for the whole relay, so each visible row asks for content only. */ @Composable -fun RelayGroupPreviewSubscription( +fun RelayGroupWarmupSubscription( channel: RelayGroupChannel, - dataSource: RelayGroupPreviewFilterAssembler, + dataSource: RelayGroupWarmupFilterAssembler, accountViewModel: AccountViewModel, contentOnly: Boolean = false, ) { val state = remember(channel.groupId, contentOnly) { - RelayGroupPreviewQueryState(channel, contentOnly) + RelayGroupWarmupQueryState(channel, contentOnly) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt index e39e019fdc..c09d8174eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt @@ -34,7 +34,7 @@ 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 RelayGroupDirectoryQueryState( +class RelayGroupsOnRelayQueryState( val relay: NormalizedRelayUrl, val account: Account, ) @@ -54,12 +54,12 @@ private val RELAY_GROUP_DIRECTORY_KINDS = * the whole directory. Consumed into per-group [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]s * keyed by (relay + group id). */ -class RelayGroupDirectoryFilterAssembler( +class RelayGroupsOnRelayFilterAssembler( client: INostrClient, -) : ComposeSubscriptionManager() { +) : ComposeSubscriptionManager() { val group = listOf( - RelayGroupDirectorySubAssembler(client, ::allKeys), + RelayGroupsOnRelaySubAssembler(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() @@ -69,12 +69,12 @@ class RelayGroupDirectoryFilterAssembler( override fun destroy() = group.forEach { it.destroy() } } -class RelayGroupDirectorySubAssembler( +class RelayGroupsOnRelaySubAssembler( client: INostrClient, - allKeys: () -> Set, -) : PerUniqueIdEoseManager(client, allKeys) { + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( - key: RelayGroupDirectoryQueryState, + key: RelayGroupsOnRelayQueryState, since: SincePerRelayMap?, ): List = listOf( @@ -89,5 +89,5 @@ class RelayGroupDirectorySubAssembler( ), ) - override fun id(key: RelayGroupDirectoryQueryState) = key.relay + override fun id(key: RelayGroupsOnRelayQueryState) = key.relay } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt index c33473ae25..f97122929f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelaySubscription.kt @@ -27,14 +27,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable -fun RelayGroupDirectorySubscription( +fun RelayGroupsOnRelaySubscription( relay: NormalizedRelayUrl, - dataSource: RelayGroupDirectoryFilterAssembler, + dataSource: RelayGroupsOnRelayFilterAssembler, accountViewModel: AccountViewModel, ) { val state = remember(accountViewModel.account, relay) { - RelayGroupDirectoryQueryState(relay, accountViewModel.account) + RelayGroupsOnRelayQueryState(relay, accountViewModel.account) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) 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 3409f6db6a..32e37e43a4 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 @@ -43,7 +43,7 @@ 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.RelayGroupServerList import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription +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 @@ -109,7 +109,7 @@ fun MessagesSinglePane( // Keep joined groups' rosters live while the messages list is on top, so // membership/pending state is accurate inline without opening each chat. - RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) Column { RelayGroupViewModeToggle(accountViewModel) 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 d40f9f0a04..b8a05e2537 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 @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomVie import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupServerList import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription +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 @@ -103,7 +103,7 @@ fun MessagesTwoPane( val viewMode by accountViewModel.account.settings.relayGroupViewMode .collectAsStateWithLifecycle() - RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) Column(Modifier.fillMaxSize()) { RelayGroupViewModeToggle(accountViewModel) From dae4ade55a142bdc02cb9485aafd0772c66e0a07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 20:22:00 +0000 Subject: [PATCH 58/81] fix: show a just-joined NIP-29 group on the Messages tab immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Messages tab (INLINE mode) mapped each joined group to its newest cached kind-9 message and dropped it when none existed. Since the joined-groups subscription only fetches roster kinds (39000/1/2), not chat, a group you just joined stayed invisible until you opened it (loading messages) or posted — unlike Marmot groups, which already fall back to a placeholder row. Mirror the Marmot pattern for relay groups: - RelayGroupChannel.placeholderNote(): a cached synthetic note that adds the channel as a gatherer, so the existing Messages row renderer resolves it back to the group (RelayGroupRoomCompose already handles a null-event note). - ChatroomListKnownFeedFilter.feed(): fall back to placeholderNote() when the group has no loaded message. - AccountFeedContentStates: rebuild dmKnown when relayGroupList (kind 10009) changes — join/leave doesn't flow through newEventBundles, so without this the placeholder wouldn't appear until a later event. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../loggedIn/AccountFeedContentStates.kt | 11 ++++++++++ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 8 ++++--- .../nip29RelayGroups/RelayGroupChannel.kt | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) 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 cb90c18215..2faf0a678e 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 @@ -167,6 +167,17 @@ 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() + } + } + // 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. 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 9d0eb04e56..a945ac5ca6 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 @@ -102,12 +102,14 @@ class ChatroomListKnownFeedFilter( if (account.settings.relayGroupViewMode.value == RelayGroupViewMode.INLINE) { account.relayGroupList.liveRelayGroupList.value.mapNotNull { groupTag -> val relay = RelayUrlNormalizer.normalizeOrNull(groupTag.relayUrl) ?: return@mapNotNull null - LocalCache - .getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay)) - .notes + val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay)) + // Newest loaded 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). + channel.notes .filter { _, it -> account.isAcceptable(it) && it.event != null } .sortedByDefaultFeedOrder() .firstOrNull() + ?: channel.placeholderNote() } } else { emptyList() 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 index d3464474d3..4a05bfa61c 100644 --- 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 @@ -23,6 +23,8 @@ 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 @@ -203,4 +205,24 @@ class RelayGroupChannel( 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()}" + } } From b935995fe6a4b3f901fc061ef0aad4e326745aae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 20:24:39 +0000 Subject: [PATCH 59/81] fix: open NIP-29 group posts in the group chat, not a thread view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a NIP-29 message in Notifications (or the feed) fell through routeFor's `else -> Route.Note`, opening the generic thread view instead of the group chat — unlike every other chat NIP. Mirror the Marmot-group path: when a note is attached to a RelayGroupChannel gatherer, route to Route.RelayGroup (the channel carries the host relay). Add an `h`-tag + provenance-relay fallback for a group-scoped note that isn't attached to a channel yet, so it still opens the chat rather than a thread. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../ui/navigation/routes/RouteMaker.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 3591a48368..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 @@ -46,6 +46,8 @@ 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 @@ -73,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) } From 7c581edddc2014327fcbbaddada3c162be63ba9f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 20:56:15 +0000 Subject: [PATCH 60/81] fix: render the NIP-29 empty-group placeholder row instead of a blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Messages placeholder guard in ChatroomHeaderCompose only recognized Marmot placeholders, so a just-joined NIP-29 relay group (an event-less placeholder note carrying a RelayGroupChannel gatherer) fell through to the wait-for-event branch and rendered BlankNote() — a white gap where the group row should be. Recognize a RelayGroupChannel gatherer too so it routes to the group row renderer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 5c4c86b7e8..00d765abfe 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 @@ -105,11 +105,14 @@ fun ChatroomHeaderCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val isEmptyMarmotPlaceholder = + // A joined Marmot or NIP-29 relay group with no messages yet is represented by an event-less + // placeholder note carrying its channel as a gatherer. Render it as the group row (via the + // gatherer) instead of waiting for an event that never arrives, which would blank the row. + val isEmptyGroupPlaceholder = baseNote.event == null && - baseNote.inGatherers?.any { it is MarmotGroupChatroom } == true + baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == true - if (baseNote.event != null || isEmptyMarmotPlaceholder) { + if (baseNote.event != null || isEmptyGroupPlaceholder) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) From a645688baf4c95ef214001553b62f47a2a15849c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:21:59 +0000 Subject: [PATCH 61/81] test+polish: verify NIP-29 placeholder gatherer; show "No messages yet" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a regression test proving RelayGroupChannel.placeholderNote() carries the channel as a gatherer and is cached/stable — this is what lets the Messages row renderer resolve the event-less placeholder back to the group (the prior fix). Also give the empty-group placeholder row a visible "No messages yet" second line instead of blank content, matching the Marmot-group row, so a just-joined group with no messages reads clearly rather than looking like an empty item. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 4 +++- amethyst/src/main/res/values/strings.xml | 1 + .../nip29RelayGroups/RelayGroupChannelTest.kt | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) 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 00d765abfe..b7bf8ec2ff 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 @@ -337,7 +337,9 @@ private fun RelayGroupRoomCompose( 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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 812814c060..c34220af72 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2007,6 +2007,7 @@ 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. 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 index d8a56e5a27..d346cfaabf 100644 --- 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 @@ -79,6 +79,20 @@ class RelayGroupChannelTest { 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() From 0366d6d58445bf7edef0e2539c35f5c54e1055a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:48:28 +0000 Subject: [PATCH 62/81] fix: render group messages on the Messages tab even when unattached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NIP-29 group message whose note isn't attached to its RelayGroupChannel (its gatherer never registered — loaded before the channel existed, or via a path that skips attach) hit ChatroomEntry's when(event) with no matching case and fell to `else -> BlankNote()`: a white, non-clickable row where the group should be. The inGatherers check only covers attached notes. Handle group-scoped events explicitly before the when: resolve the group from the event's `h` tag + the note's provenance relay and render RelayGroupRoomCompose, so the row shows the group and taps through to the chat. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../chats/rooms/ChatroomHeaderCompose.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 b7bf8ec2ff..ff59f05f9e 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 @@ -58,6 +58,7 @@ 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 @@ -97,6 +98,9 @@ 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 @@ -159,6 +163,24 @@ private fun ChatroomEntry( 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 From 5425b09fef611bf7eda7157f0a32c2c9cff862a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:11:44 +0000 Subject: [PATCH 63/81] fix: don't treat a group reaction as the group's latest message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filterRelevantRelayGroupMessages picked ANY group-scoped note (isGroupScoped() = carries the group's `h` tag), so a reaction (kind 7) to my message became the group's "last message" on the Messages tab — a wrong row that the chat renderer can't display. Whitelist actual chat content (kind 9 chat / 1068 poll / 11 thread / 1111 comment) and reject reactions, deletions, labels, etc. Apply the same guard to the feed()'s channel-notes scan as defense in depth. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 a945ac5ca6..08e632594d 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 @@ -33,12 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.nip22Comments.CommentEvent 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.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent class ChatroomListKnownFeedFilter( val account: Account, @@ -103,10 +107,11 @@ class ChatroomListKnownFeedFilter( 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 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). + // 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.notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } + .filter { _, it -> account.isAcceptable(it) && it.event != null && it.isRelayGroupChatContent() } .sortedByDefaultFeedOrder() .firstOrNull() ?: channel.placeholderNote() @@ -284,6 +289,15 @@ class ChatroomListKnownFeedFilter( return newRelevantEphemeralChats } + // Only actual group content represents a room in the Messages list. A reaction (kind 7), + // deletion, label, etc. carries the group's `h` tag too, so isGroupScoped() alone would let a + // reaction to my message become the group's "last message" — a wrong, unrenderable row. + private fun Note.isRelayGroupChatContent(): Boolean = + when (event) { + is ChatEvent, is PollEvent, is ThreadEvent, is CommentEvent -> true + else -> false + } + /** Latest message per joined NIP-29 group (inline view mode), keyed by group id. */ private fun filterRelevantRelayGroupMessages( newItems: Set, @@ -295,7 +309,7 @@ class ChatroomListKnownFeedFilter( .mapTo(HashSet()) { it.groupId } val result = mutableMapOf() newItems.forEach { newNote -> - val gid = newNote.event?.takeIf { it.isGroupScoped() }?.groupId() + val gid = newNote.event?.takeIf { it.isGroupScoped() && newNote.isRelayGroupChatContent() }?.groupId() if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) { val lastNote = result[gid] if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { From b36daf80f6cbaaa9244a090a96a47272b3711bf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:15:54 +0000 Subject: [PATCH 64/81] feat: notify on reactions/replies to my messages in joined groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notification events were only fetched from my inbox relays (#p=me), but NIP-29 group activity — a reaction or reply to my message — lives on the group's HOST relay, so it never surfaced in Notifications until I opened the group (which subscribes to the group's content directly). Extend the notifications subscription to also poll each joined group's host relay for events that tag me, scoped by `#h` to my joined groups (new filterGroupNotificationsToPubkey over reaction/reply/repost/zap/report kinds). Re-subscribe when the joined-group list changes so a newly-joined group's relay is added. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- ...NotificationsEoseFromInboxRelaysManager.kt | 49 ++++++++++++++----- .../FilterNotificationsToPubkey.kt | 47 ++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) 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?, From 7c7608913d1dfefc0321d7e642ec9dad728e471e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:39:15 +0000 Subject: [PATCH 65/81] fix: never render a group reaction as the Messages row content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Messages-list feed already filtered kind-9/1068/11/1111 content when selecting a group's representative note, but the ChatroomEntry render fallback still rendered ANY group-scoped note — including a kind-7 reaction lingering in the in-memory list — as the group row, using the reaction's content and time. Add a shared quartz helper `Event.isGroupChatContent()` and use it in both places: the feed filter and the render fallback. A non-content group-scoped note now falls back to the channel placeholder ("No messages yet") instead of showing the reaction as the room's last message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../chats/rooms/ChatroomHeaderCompose.kt | 13 ++++++------ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 21 ++++++------------- .../quartz/nip29RelayGroups/GroupScope.kt | 17 +++++++++++++++ 3 files changed, 30 insertions(+), 21 deletions(-) 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 ff59f05f9e..9d255ad439 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 @@ -100,6 +100,7 @@ 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 import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -171,12 +172,12 @@ private fun ChatroomEntry( val gid = groupScopedEvent.groupId() val hostRelay = lastMessage.relays.firstOrNull() if (gid != null && hostRelay != null) { - RelayGroupRoomCompose( - lastMessage, - LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)), - accountViewModel, - nav, - ) + val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)) + // Only actual chat content is the room's "last message". A group-scoped reaction/ + // deletion/label lingering in the list must not render as the row (its content/time + // aren't a message) — fall back to the channel placeholder so the group still shows. + val rowNote = if (groupScopedEvent.isGroupChatContent()) lastMessage else channel.placeholderNote() + RelayGroupRoomCompose(rowNote, channel, accountViewModel, nav) return } } 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 08e632594d..0dfeb9a12d 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 @@ -33,16 +33,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.nip22Comments.CommentEvent 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 -import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nipC7Chats.ChatEvent class ChatroomListKnownFeedFilter( val account: Account, @@ -111,7 +108,7 @@ class ChatroomListKnownFeedFilter( // 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.notes - .filter { _, it -> account.isAcceptable(it) && it.event != null && it.isRelayGroupChatContent() } + .filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true } .sortedByDefaultFeedOrder() .firstOrNull() ?: channel.placeholderNote() @@ -289,15 +286,6 @@ class ChatroomListKnownFeedFilter( return newRelevantEphemeralChats } - // Only actual group content represents a room in the Messages list. A reaction (kind 7), - // deletion, label, etc. carries the group's `h` tag too, so isGroupScoped() alone would let a - // reaction to my message become the group's "last message" — a wrong, unrenderable row. - private fun Note.isRelayGroupChatContent(): Boolean = - when (event) { - is ChatEvent, is PollEvent, is ThreadEvent, is CommentEvent -> true - else -> false - } - /** Latest message per joined NIP-29 group (inline view mode), keyed by group id. */ private fun filterRelevantRelayGroupMessages( newItems: Set, @@ -309,7 +297,10 @@ class ChatroomListKnownFeedFilter( .mapTo(HashSet()) { it.groupId } val result = mutableMapOf() newItems.forEach { newNote -> - val gid = newNote.event?.takeIf { it.isGroupScoped() && newNote.isRelayGroupChatContent() }?.groupId() + // Only actual group content represents a room. A reaction (kind 7), deletion, label, + // etc. carries the group's `h` tag too, so isGroupScoped() alone would let a reaction to + // my message become the group's "last message" — a wrong, unrenderable row. + 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)) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt index b2141b96b4..fd88ddf8b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupScope.kt @@ -24,7 +24,11 @@ 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. @@ -55,3 +59,16 @@ 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 From d4e71881e835a9a92219902932e9ae7f704f2994 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:17:03 +0000 Subject: [PATCH 66/81] fix: sign a q quote-tag when replying to a kind-9 group message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-29 group composer always built replies with ChatEvent.build and ignored replyTo entirely, so a reply to a kind-9 message quoted its parent in the UI but the signed event carried no NIP-18 `q` tag (and no `p` notify to the author) — unlike the public-chat and live-activity paths, which use `.reply(...)`. Route group replies through ChatEvent.reply when replyTo is set: it emits the `q` quote tag, and we add a `p` tag to the parent's author. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../send/ChannelNewMessageViewModel.kt | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) 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 87d0976198..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 @@ -77,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 @@ -550,17 +551,38 @@ open class ChannelNewMessageViewModel : // 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. - ChatEvent.build(tagger.message) { - hTag(channel.groupId.id) + 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) } + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } - emojis(emojis) - imetas(usedAttachments) + 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) + } } } From 6292ceb8036c33b2dfb59dda1f88fa88da8630d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:17:19 +0000 Subject: [PATCH 67/81] feat: grouped-by-relay Messages view + a Messages settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the NIP-29 inline/by-relay toggle off the top of the Messages tab (where it sat clipped behind the tab row) into a new Settings › Messages screen, and make "by relay" actually mean something in the feed. - New MessagesSettingsScreen (Route + SettingsCatalog entry + nav) with a radio choice: show each joined group inline, or collapse each relay's groups into one row. Removes the pinned SegmentedButton + above-pager server list from both the single- and two-pane layouts; deletes the now-dead RelayGroupViewModeToggle and RelayGroupServerList composables. - GROUPED mode now weaves one row PER HOST RELAY (never duplicated) into the Messages feed, positioned at that relay's newest group message so it interleaves with DMs by recency and shows the last message. Backed by a synthetic RelayGroupServerRoomNote whose createdAt mirrors the newest message; ChatroomListKnownFeedFilter builds/updates it in feed(), applyFilter and updateListWith, keyed by relay url. A view-mode change forces a feed rebuild. - Revert the moot kind-7 render guard: the data-layer content filter already keeps reactions out of the row, so the ChatroomEntry fallback stays simple (the shared Event.isGroupChatContent helper remains, used by the feed filter). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 11 ++ .../relayGroup/RelayGroupBrowseScreen.kt | 2 +- .../relayGroup/RelayGroupServerList.kt | 61 +------- .../relayGroup/RelayGroupViewModeToggle.kt | 77 ---------- .../chats/rooms/ChatroomHeaderCompose.kt | 75 ++++++++-- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 131 +++++++++++++----- .../rooms/dal/RelayGroupServerRoomNote.kt | 47 +++++++ .../rooms/singlepane/MessagesSinglePane.kt | 30 ++-- .../chats/rooms/twopane/MessagesTwoPane.kt | 32 ++--- .../settings/MessagesSettingsScreen.kt | 131 ++++++++++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 6 + 14 files changed, 375 insertions(+), 233 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/RelayGroupServerRoomNote.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt 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 75e89017b0..85f86e9c1e 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 @@ -221,6 +221,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 @@ -473,6 +474,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) } 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 b6301cffc7..7962a3d383 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() 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 86c9897bfd..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 @@ -179,6 +179,17 @@ class AccountFeedContentStates( } } + // 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. 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 index fee8bfbe3e..fbbb7cbe18 100644 --- 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 @@ -75,7 +75,7 @@ private val POPULAR_RELAYS = * 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 [RelayGroupServerList] can't offer. + * that the joined-only Messages list can't offer. */ @Composable fun RelayGroupBrowseScreen( 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 index 7270680b71..e3068e3e06 100644 --- 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 @@ -27,82 +27,23 @@ 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.HorizontalDivider import androidx.compose.material3.MaterialTheme 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.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.model.nip11RelayInfo.loadRelayInfo -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -/** - * The "grouped" Messages view: one row per host relay of the user's joined NIP-29 - * groups, each with the relay's NIP-11 avatar and name. Tapping a relay opens its - * channel list. Rendered above the DM feed only in - * [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED]. - */ -@Composable -fun RelayGroupServerList( - accountViewModel: AccountViewModel, - nav: INav, -) { - val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers - .collectAsStateWithLifecycle() - - Column(Modifier.fillMaxWidth()) { - servers.sorted().forEach { server -> - RelayGroupServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) } - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) - } - - // Always offer a discovery entry, so a user with no groups yet still has - // somewhere to start — the Relay Groups tab is the discovery feed. - FindChannelsRow { nav.nav(Route.RelayGroups) } - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) - } -} - -@Composable -private fun FindChannelsRow(onClick: () -> Unit) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) - Text( - text = stringRes(R.string.relay_group_browse_title), - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - } -} - -/** One relay row: NIP-11 avatar + name, used by the grouped list and the browse screen. */ +/** One relay row: NIP-11 avatar + name, used by the browse screen. */ @Composable fun RelayGroupServerRow( relayUrl: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt deleted file mode 100644 index b45d706b66..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupViewModeToggle.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * A compact toggle to switch how the user's NIP-29 groups appear in the Messages - * tab — [RelayGroupViewMode.INLINE] (channels as rows with a relay chip) vs - * [RelayGroupViewMode.GROUPED] (a row per relay, drill in for its channels). - * Only shown once the user has joined at least one group. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RelayGroupViewModeToggle(accountViewModel: AccountViewModel) { - val groups by accountViewModel.account.relayGroupList.liveRelayGroupList - .collectAsStateWithLifecycle() - if (groups.isEmpty()) return - - val mode by accountViewModel.account.settings.relayGroupViewMode - .collectAsStateWithLifecycle() - - SingleChoiceSegmentedButtonRow( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - ) { - SegmentedButton( - selected = mode == RelayGroupViewMode.INLINE, - onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) { - Text(stringRes(R.string.relay_group_view_inline)) - } - SegmentedButton( - selected = mode == RelayGroupViewMode.GROUPED, - onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) { - Text(stringRes(R.string.relay_group_view_grouped)) - } - } -} 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 9d255ad439..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 @@ -82,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 @@ -100,7 +101,6 @@ 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 import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -110,14 +110,18 @@ fun ChatroomHeaderCompose( accountViewModel: AccountViewModel, nav: INav, ) { - // A joined Marmot or NIP-29 relay group with no messages yet is represented by an event-less - // placeholder note carrying its channel as a gatherer. Render it as the group row (via the - // gatherer) instead of waiting for an event that never arrives, which would blank the row. - val isEmptyGroupPlaceholder = - baseNote.event == null && - baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == 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 || isEmptyGroupPlaceholder) { + if (baseNote.event != null || rendersWithoutEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) @@ -152,6 +156,11 @@ 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) @@ -172,12 +181,12 @@ private fun ChatroomEntry( val gid = groupScopedEvent.groupId() val hostRelay = lastMessage.relays.firstOrNull() if (gid != null && hostRelay != null) { - val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)) - // Only actual chat content is the room's "last message". A group-scoped reaction/ - // deletion/label lingering in the list must not render as the row (its content/time - // aren't a message) — fall back to the channel placeholder so the group still shows. - val rowNote = if (groupScopedEvent.isGroupChatContent()) lastMessage else channel.placeholderNote() - RelayGroupRoomCompose(rowNote, channel, accountViewModel, nav) + RelayGroupRoomCompose( + lastMessage, + LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)), + accountViewModel, + nav, + ) return } } @@ -397,6 +406,44 @@ private fun RelayGroupRoomCompose( ) } +@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( 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 0dfeb9a12d..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,7 @@ */ 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 @@ -30,6 +31,7 @@ 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 @@ -95,26 +97,35 @@ class ChatroomListKnownFeedFilter( } } - // NIP-29 relay groups the user joined (kind 10009) appear inline in the - // Messages list (the "inline" view mode), each row tagged with its host - // relay. In the "grouped" mode they're reached via relay rows instead, so - // they're excluded from this flat feed. + // 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 = - if (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.notes - .filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true } - .sortedByDefaultFeedOrder() - .firstOrNull() - ?: channel.placeholderNote() - } - } else { - emptyList() + 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()) @@ -196,8 +207,7 @@ class ChatroomListKnownFeedFilter( newRelevantRelayGroups.forEach { newNotePair -> var hasUpdated = false oldList.forEach { oldNote -> - val oldGroupId = oldNote.event?.takeIf { it.isGroupScoped() }?.groupId() - if (newNotePair.key == oldGroupId) { + if (newNotePair.key == oldNote.relayGroupRowKey()) { hasUpdated = true if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) { myNewList = myNewList.replace(oldNote, newNotePair.value) @@ -286,29 +296,76 @@ class ChatroomListKnownFeedFilter( return newRelevantEphemeralChats } - /** Latest message per joined NIP-29 group (inline view mode), keyed by group id. */ + /** 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 { - if (account.settings.relayGroupViewMode.value != RelayGroupViewMode.INLINE) return mutableMapOf() - val joinedGroupIds = - account.relayGroupList.liveRelayGroupList.value - .mapTo(HashSet()) { it.groupId } - val result = mutableMapOf() - newItems.forEach { newNote -> - // Only actual group content represents a room. A reaction (kind 7), deletion, label, - // etc. carries the group's `h` tag too, so isGroupScoped() alone would let a reaction to - // my message become the group's "last message" — a wrong, unrenderable row. - 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 + 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 } } - return result } private fun filterRelevantPrivateMessages( 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/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 32e37e43a4..1f0bc4aef4 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,15 +21,14 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -41,8 +40,6 @@ 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.RelayGroupServerList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle 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 @@ -104,25 +101,18 @@ fun MessagesSinglePane( }, accountViewModel = accountViewModel, ) { - val viewMode by accountViewModel.account.settings.relayGroupViewMode - .collectAsStateWithLifecycle() - // 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) - Column { - RelayGroupViewModeToggle(accountViewModel) - if (viewMode == RelayGroupViewMode.GROUPED) { - RelayGroupServerList(accountViewModel, nav) - } - MessagesPager( - pagerState, - tabs, - accountViewModel, - nav, - modifier = Modifier.weight(1f), - ) - } + // 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 b8a05e2537..7f0a3c60a1 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 @@ -21,26 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -51,8 +46,6 @@ 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.RelayGroupServerList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle 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 @@ -100,25 +93,16 @@ fun MessagesTwoPane( TwoPane( first = { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - val viewMode by accountViewModel.account.settings.relayGroupViewMode - .collectAsStateWithLifecycle() - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - Column(Modifier.fillMaxSize()) { - RelayGroupViewModeToggle(accountViewModel) - if (viewMode == RelayGroupViewMode.GROUPED) { - RelayGroupServerList(accountViewModel, nav) - } - Box(Modifier.weight(1f).fillMaxWidth()) { - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - } - } + // 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, + accountViewModel, + twoPaneNav, + ) Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { ChannelFabColumn(nav) 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 c34220af72..f70da7d59f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1932,6 +1932,12 @@ 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 Group name Topic (optional) From 1ecc97de7669b6f9c63c9dc452136d5061d24a2a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:35:40 +0000 Subject: [PATCH 68/81] feat: compose NIP-29 group threads in the full ShortNotePostScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group "new thread" FAB opened a cramped title+body screen. Point it at the rich ShortNotePostScreen instead (attachments, emoji, previews, markdown), and teach that composer to emit a kind-11 group thread when opened for a group. - Route.NewShortNote gains groupThreadId + groupThreadRelayUrl; the group Threads FAB navigates there. - ShortNotePostViewModel.setGroupThread arms a group-thread mode: createTemplate builds a kind-11 ThreadEvent (title = first line, body = the rest, `h` scope), and sendPostSync publishes it ONLY to the group's host relay via signAndSendPrivatelyOrBroadcast — bypassing the outbox/private/scheduled paths. - The poll, private-note and scheduling toggles are hidden in group-thread mode (they don't apply / would break the host-relay pin). - Delete the now-unused RelayGroupNewThreadScreen and its route/nav dispatch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/navigation/AppNavigation.kt | 12 +- .../amethyst/ui/navigation/routes/Routes.kt | 9 +- .../relayGroup/RelayGroupNewThreadScreen.kt | 129 ------------------ .../relayGroup/RelayGroupThreadsScreen.kt | 9 +- .../loggedIn/home/ShortNotePostScreen.kt | 18 ++- .../loggedIn/home/ShortNotePostViewModel.kt | 63 ++++++++- 6 files changed, 89 insertions(+), 151 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt 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 85f86e9c1e..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 @@ -112,7 +112,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG 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.RelayGroupNewThreadScreen 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 @@ -605,15 +604,6 @@ fun BuildNavigation( ) } - composableFromEndArgs { - RelayGroupNewThreadScreen( - id = it.id, - relayUrl = it.relayUrl, - accountViewModel = accountViewModel, - nav = nav, - ) - } - composableFromEndArgs { RelayGroupCreateScreen( relayUrl = it.relayUrl, @@ -750,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/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 7962a3d383..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 @@ -663,11 +663,6 @@ sealed class Route { val relayUrl: String, ) : Route() - @Serializable data class RelayGroupNewThread( - val id: String, - val relayUrl: String, - ) : Route() - @Serializable data class RelayGroupCreate( val relayUrl: String, ) : Route() @@ -844,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/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt deleted file mode 100644 index 34443cf40b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -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.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip29RelayGroups.GroupId - -/** - * Full-screen composer for a new kind-11 group thread (a title plus a body). Replaces - * the old cramped dialog: state is [rememberSaveable] so a half-written thread survives - * rotation / process death, and the group's host relay accepts it only from a member. - */ -@Composable -fun RelayGroupNewThreadScreen( - id: HexKey, - relayUrl: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return - val groupId = remember(id, relay) { GroupId(id, relay) } - - var title by rememberSaveable { mutableStateOf("") } - var body by rememberSaveable { mutableStateOf("") } - - LoadRelayGroupChannel(groupId, accountViewModel) { channel -> - Scaffold( - topBar = { - TopBarExtensibleWithBackButton( - title = { - Column { - Text( - text = stringRes(R.string.relay_group_thread_new), - 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, - ) - } - }, - actions = { - TextButton( - enabled = title.isNotBlank() && body.isNotBlank(), - onClick = { - accountViewModel.postRelayGroupThread(channel, title.trim(), body.trim()) - nav.popBack() - }, - ) { - Text(stringRes(R.string.relay_group_thread_post)) - } - }, - popBack = nav::popBack, - ) - }, - ) { padding -> - Column( - Modifier - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - OutlinedTextField( - value = title, - onValueChange = { title = it }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_thread_title_label)) }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = body, - onValueChange = { body = it }, - label = { Text(stringRes(R.string.relay_group_thread_body_label)) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp).padding(top = 8.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 index 7eaf2d5f8b..d38d971d05 100644 --- 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 @@ -127,7 +127,14 @@ private fun RelayGroupThreads( floatingActionButton = { if (canPost) { FloatingActionButton( - onClick = { nav.nav(Route.RelayGroupNewThread(channel.groupId.id, channel.groupId.relayUrl.url)) }, + onClick = { + nav.nav( + Route.NewShortNote( + groupThreadId = channel.groupId.id, + groupThreadRelayUrl = channel.groupId.relayUrl.url, + ), + ) + }, shape = CircleShape, ) { Icon( 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..4bcb503a87 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 @@ -149,11 +149,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() @@ -749,8 +752,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 +763,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) { @@ -790,8 +797,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) } 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..4e506fc6af 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 @@ -117,6 +120,7 @@ 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 +137,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 @@ -479,6 +484,28 @@ 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 + } + open fun load( replyingTo: Note?, quote: Note?, @@ -920,8 +947,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 @@ -1074,7 +1112,30 @@ open class ShortNotePostViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null - return if (wantsPoll) { + val threadTarget = groupThreadTarget + return if (threadTarget != null) { + // NIP-29 kind-11 group thread: title = first line, body = the rest (or the whole line + // when single-line). Scoped to the group by `h`; the host relay authorizes the write. + val text = tagger.message + val firstBreak = text.indexOf('\n') + val title = (if (firstBreak >= 0) text.substring(0, firstBreak) else text).trim() + val body = (if (firstBreak >= 0) text.substring(firstBreak + 1) else text).trim() + + ThreadEvent.build(body, title) { + 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 From cb90362bae33cdcaac2f09235163f780781ea2ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:13:53 +0000 Subject: [PATCH 69/81] feat: warm each group's recent messages on a relay's channel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a group from the relay's channel list used to start its chat from a cold load. Mount the existing RelayGroupWarmupSubscription on every visible card (content-only — the directory subscription already streams metadata), so a tap lands on already-cached messages. Add a contentLimit to the warmup (default 50, unchanged for discovery's "50+" signal); the channel list passes ~10 — a first screen's worth. Bounded to visible rows by the LazyColumn and released as they scroll off. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupChannelListScreen.kt | 19 ++++++++++++++++++- .../RelayGroupWarmupFilterAssembler.kt | 13 ++++++++----- .../RelayGroupWarmupSubscription.kt | 5 +++-- 3 files changed, 29 insertions(+), 8 deletions(-) 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 index 62abf89d7a..b1c16cf924 100644 --- 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 @@ -60,6 +60,7 @@ 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 @@ -67,11 +68,15 @@ 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. + * 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( @@ -156,6 +161,18 @@ private fun RelayGroupChannelRow( 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 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 index fb26d7b829..5aff994a54 100644 --- 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 @@ -40,6 +40,8 @@ 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. */ @@ -47,11 +49,12 @@ private val RELAY_GROUP_WARMUP_CONTENT_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND) /** - * How many 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). + * 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]. */ -private const val RELAY_GROUP_WARMUP_LIMIT = 50 +const val RELAY_GROUP_WARMUP_LIMIT = 50 /** * Warms a NIP-29 group referenced inline (a group-link card) without opening it: @@ -92,7 +95,7 @@ class RelayGroupWarmupSubAssembler( Filter( kinds = RELAY_GROUP_WARMUP_CONTENT_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), - limit = RELAY_GROUP_WARMUP_LIMIT, + limit = key.contentLimit, since = since?.get(groupId.relayUrl)?.time, ), ) 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 index 86440fdae9..fe8d9aafd8 100644 --- 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 @@ -37,10 +37,11 @@ fun RelayGroupWarmupSubscription( dataSource: RelayGroupWarmupFilterAssembler, accountViewModel: AccountViewModel, contentOnly: Boolean = false, + contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT, ) { val state = - remember(channel.groupId, contentOnly) { - RelayGroupWarmupQueryState(channel, contentOnly) + remember(channel.groupId, contentOnly, contentLimit) { + RelayGroupWarmupQueryState(channel, contentOnly, contentLimit) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) From bf288711a2562499945e1cf7f8c1c7bdfd4dbb11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:14:05 +0000 Subject: [PATCH 70/81] feat: optional subject/title field in the ShortNotePostScreen composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a subject line toggled from the composer's bottom row. On a kind-1 note it becomes a NIP-14 `subject` tag; on a NIP-29 kind-11 group thread it becomes the `title` (superseding the first-line-as-title heuristic — that stays as the fallback when the field is left empty). The field is auto-shown for group threads and labelled "Title" there, "Subject" otherwise. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../loggedIn/home/ShortNotePostScreen.kt | 40 +++++++++++++++++++ .../loggedIn/home/ShortNotePostViewModel.kt | 28 +++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) 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 4bcb503a87..b84173874a 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 @@ -45,6 +45,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton 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 @@ -350,6 +351,26 @@ private fun NewPostScreenBody( ) } + if (postViewModel.wantsSubject) { + OutlinedTextField( + value = postViewModel.subjectText, + onValueChange = { postViewModel.subjectText = it }, + singleLine = true, + label = { + Text( + stringRes( + if (postViewModel.groupThreadTarget != null) { + R.string.relay_group_thread_title_label + } else { + R.string.messages_new_message_subject + }, + ), + ) + }, + modifier = Modifier.fillMaxWidth().padding(vertical = Size10dp), + ) + } + // Only show text input if no voice message is being posted if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { Row( @@ -788,6 +809,10 @@ private fun BottomRowActions( } } + AddSubjectButton(postViewModel.wantsSubject) { + postViewModel.toggleSubject() + } + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { postViewModel.toggleMarkAsSensitive() } @@ -857,6 +882,21 @@ private fun AddPrivateNoteButton( } } +@Composable +private fun AddSubjectButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.AutoMirrored.Article, + 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 4e506fc6af..90ea7d1755 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 @@ -116,6 +116,7 @@ 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 @@ -310,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) + var subjectText by mutableStateOf("") + // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) var contentWarningDescription by mutableStateOf("") @@ -504,6 +510,8 @@ open class ShortNotePostViewModel : ) { 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( @@ -1114,12 +1122,14 @@ open class ShortNotePostViewModel : val threadTarget = groupThreadTarget return if (threadTarget != null) { - // NIP-29 kind-11 group thread: title = first line, body = the rest (or the whole line - // when single-line). Scoped to the group by `h`; the host relay authorizes the write. + // NIP-29 kind-11 group thread. The title is the explicit subject field when filled; + // otherwise it falls back to the first line (body = the rest). Scoped to the group by + // `h`; the host relay authorizes the write. + val subject = if (wantsSubject) subjectText.trim() else "" val text = tagger.message val firstBreak = text.indexOf('\n') - val title = (if (firstBreak >= 0) text.substring(0, firstBreak) else text).trim() - val body = (if (firstBreak >= 0) text.substring(firstBreak + 1) else text).trim() + val title = subject.ifEmpty { (if (firstBreak >= 0) text.substring(0, firstBreak) else text).trim() } + val body = if (subject.isNotEmpty()) text.trim() else (if (firstBreak >= 0) text.substring(firstBreak + 1) else text).trim() ThreadEvent.build(body, title) { hTag(threadTarget.groupId) @@ -1261,6 +1271,8 @@ open class ShortNotePostViewModel : references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + if (wantsSubject && subjectText.isNotBlank()) subject(subjectText.trim()) + geoHash?.let { geohash(it) } localZapRaiserAmount?.let { zapraiser(it) } zapReceiver?.let { zapSplits(it) } @@ -1434,6 +1446,8 @@ open class ShortNotePostViewModel : zapRaiserAmount.value = null wantsForwardZapTo = false + wantsSubject = false + subjectText = "" wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false @@ -1821,6 +1835,12 @@ open class ShortNotePostViewModel : draftTag.newVersion() } + fun toggleSubject() { + wantsSubject = !wantsSubject + if (!wantsSubject) subjectText = "" + draftTag.newVersion() + } + fun toggleExpirationDate() { wantsExpirationDate = !wantsExpirationDate if (wantsExpirationDate) { From d650c184deb105e57021cb03842d3e1a486b5537 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:21:25 +0000 Subject: [PATCH 71/81] feat: require the title for NIP-29 group threads (no text fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group-thread composer now treats the subject/title field as mandatory: the field is always shown (no toggle to hide it), canPost() blocks until it's filled, and createTemplate uses it verbatim as the kind-11 title — dropping the previous first-line-of-the-body fallback. Plain kind-1 notes keep the optional subject. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../screen/loggedIn/home/ShortNotePostScreen.kt | 9 ++++++--- .../loggedIn/home/ShortNotePostViewModel.kt | 17 +++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) 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 b84173874a..c2ff826192 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 @@ -351,7 +351,7 @@ private fun NewPostScreenBody( ) } - if (postViewModel.wantsSubject) { + if (postViewModel.wantsSubject || postViewModel.groupThreadTarget != null) { OutlinedTextField( value = postViewModel.subjectText, onValueChange = { postViewModel.subjectText = it }, @@ -809,8 +809,11 @@ private fun BottomRowActions( } } - AddSubjectButton(postViewModel.wantsSubject) { - postViewModel.toggleSubject() + // 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) { 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 90ea7d1755..4d340aada2 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 @@ -1122,16 +1122,10 @@ open class ShortNotePostViewModel : val threadTarget = groupThreadTarget return if (threadTarget != null) { - // NIP-29 kind-11 group thread. The title is the explicit subject field when filled; - // otherwise it falls back to the first line (body = the rest). Scoped to the group by - // `h`; the host relay authorizes the write. - val subject = if (wantsSubject) subjectText.trim() else "" - val text = tagger.message - val firstBreak = text.indexOf('\n') - val title = subject.ifEmpty { (if (firstBreak >= 0) text.substring(0, firstBreak) else text).trim() } - val body = if (subject.isNotEmpty()) text.trim() else (if (firstBreak >= 0) text.substring(firstBreak + 1) else text).trim() - - ThreadEvent.build(body, title) { + // 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(), subjectText.trim()) { hTag(threadTarget.groupId) hashtags(findHashtags(tagger.message)) @@ -1577,6 +1571,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 && subjectText.isBlank()) return false + // Regular text/media posts require text return message.text.toString().isNotBlank() && !mediaUploadTracker.isUploading && From 567cc3f4163f141e14db8ab2876f19d9c6164e93 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:29:41 +0000 Subject: [PATCH 72/81] style: match the composer subject/title field to the new-DM To field Restyle the ShortNotePostScreen subject/title input to the inline-label + borderless ThinPaddingTextField + hairline-divider look used by the new-DM composer's "To"/"Subject" rows, instead of a boxed OutlinedTextField. The field now backs onto a TextFieldState (like the DM composer) rather than a String. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../loggedIn/home/ShortNotePostScreen.kt | 56 +++++++++++++------ .../loggedIn/home/ShortNotePostViewModel.kt | 13 +++-- 2 files changed, 47 insertions(+), 22 deletions(-) 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 c2ff826192..184913312a 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,9 +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.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -62,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 @@ -86,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 @@ -123,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 @@ -352,23 +358,41 @@ private fun NewPostScreenBody( } if (postViewModel.wantsSubject || postViewModel.groupThreadTarget != null) { - OutlinedTextField( - value = postViewModel.subjectText, - onValueChange = { postViewModel.subjectText = it }, - singleLine = true, - label = { + // 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( - stringRes( - if (postViewModel.groupThreadTarget != null) { - R.string.relay_group_thread_title_label - } else { - R.string.messages_new_message_subject - }, - ), + 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, ) - }, - modifier = Modifier.fillMaxWidth().padding(vertical = Size10dp), - ) + 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 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 4d340aada2..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 @@ -314,7 +314,7 @@ open class ShortNotePostViewModel : // 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) - var subjectText by mutableStateOf("") + val subject = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -1119,13 +1119,14 @@ open class ShortNotePostViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null + val subjectValue = subject.text.toString().trim() 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(), subjectText.trim()) { + ThreadEvent.build(tagger.message.trim(), subjectValue) { hTag(threadTarget.groupId) hashtags(findHashtags(tagger.message)) @@ -1265,7 +1266,7 @@ open class ShortNotePostViewModel : references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) - if (wantsSubject && subjectText.isNotBlank()) subject(subjectText.trim()) + if (wantsSubject && subjectValue.isNotBlank()) subject(subjectValue) geoHash?.let { geohash(it) } localZapRaiserAmount?.let { zapraiser(it) } @@ -1441,7 +1442,7 @@ open class ShortNotePostViewModel : wantsForwardZapTo = false wantsSubject = false - subjectText = "" + subject.clearText() wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false @@ -1572,7 +1573,7 @@ open class ShortNotePostViewModel : } // A NIP-29 group thread requires a title — it has no first-line fallback. - if (groupThreadTarget != null && subjectText.isBlank()) return false + if (groupThreadTarget != null && subject.text.isBlank()) return false // Regular text/media posts require text return message.text.toString().isNotBlank() && @@ -1834,7 +1835,7 @@ open class ShortNotePostViewModel : fun toggleSubject() { wantsSubject = !wantsSubject - if (!wantsSubject) subjectText = "" + if (!wantsSubject) subject.clearText() draftTag.newVersion() } From 9a5a278b012461689835de54487df3aad062cd0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 13:13:30 +0000 Subject: [PATCH 73/81] feat: block NIP-29 group creation on relays that don't advertise it The reported "title/image didn't save, group shows as a bare hex, and I'm asked to join my own group" all come from creating a group on a relay that isn't running NIP-29: it stores the 9007/9002 as ordinary events but never creates the group, emits 39000/39001/39002 metadata, or makes the creator an admin. Gate the create screen on the relay advertising NIP-29 in its NIP-11 `supported_nips`: a tri-state check (checking / unsupported / supported) disables the Create button until support is confirmed and shows an explanatory warning banner when the relay is confirmed to lack it. Editing is unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupMetadataScreen.kt | 53 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 53 insertions(+), 1 deletion(-) 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 index b7b07d148e..66dc72dd20 100644 --- 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 @@ -37,6 +37,7 @@ 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 @@ -48,6 +49,7 @@ 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 @@ -59,6 +61,7 @@ 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 @@ -89,10 +92,23 @@ fun RelayGroupCreateScreen( 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) }, ) } @@ -142,6 +158,10 @@ private fun RelayGroupMetadataScaffold( 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() @@ -169,7 +189,7 @@ private fun RelayGroupMetadataScaffold( if (viewModel.isNewGroup) { CreatingTopBar( titleRes = R.string.relay_group_create_title, - isActive = viewModel::canPost, + isActive = { viewModel.canPost && nip29Support == true }, onCancel = nav::popBack, onPost = onSubmit, ) @@ -196,6 +216,11 @@ private fun RelayGroupMetadataScaffold( .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)) @@ -206,6 +231,32 @@ private fun RelayGroupMetadataScaffold( } } +/** 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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f70da7d59f..f275053e00 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1939,6 +1939,7 @@ 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) From 188ba3c609d4432fc2da14c1c55888f0989d9d90 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:32:30 +0000 Subject: [PATCH 74/81] feat: discovery only shows groups from relays that advertise NIP-29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stray kind-39000 events published by ordinary users to general relays (e.g. nostr.wine) were surfacing in the Relay Groups discovery feed as joinable groups that have no roster and no chat and can't actually be joined — because those relays don't run NIP-29, they just store the fake metadata like any addressable event. A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a relay every 39000 is relay-signed and genuine. Gate discovery on that: restrict the per-relay constraint set to relays whose NIP-11 `supported_nips` advertises 29. This is the single point both the match test and the REQ-driven feed read, so non-advertising relays drop out wholesale. The discovery screen warms each candidate relay's NIP-11 and re-invalidates the feed as support resolves (a relay whose NIP-11 lands after its 39000s would otherwise stay hidden until a manual refresh). "My Groups" (the kind-10009 joined list) is unaffected. Trade-off: a relay that runs NIP-29 but doesn't publish NIP-11 (or omits 29 from its list) is hidden from discovery until it advertises. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../model/nip11RelayInfo/RelaySupportsNip.kt | 42 +++++++++++++++++++ .../relayGroup/RelayGroupDiscoveryScreen.kt | 28 ++++++++++++- .../dal/RelayGroupDiscoveryFeedFilter.kt | 15 ++++++- 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt 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..a3e5020d41 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.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.model.nip11RelayInfo + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * 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") 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 index 4c71a6190d..405a65d4db 100644 --- 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 @@ -42,7 +42,9 @@ 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.draw.clip @@ -51,6 +53,7 @@ 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.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -75,6 +78,7 @@ 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 @@ -215,7 +219,29 @@ private fun WatchAccountForRelayGroupDiscovery( val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList .collectAsStateWithLifecycle() - LaunchedEffect(listName, perRelay, joinedGroups) { + // Discovery only shows groups hosted on relays that actually run NIP-29 (a relay that does + // rejects user-authored 39xxx, so its 39000s are all genuine; general relays instead carry stray + // fake ones). The feed decides this from each relay's cached NIP-11 `supported_nips`, so warm + // the candidate relays here and re-invalidate as support is confirmed — otherwise a relay whose + // NIP-11 lands after its 39000s would stay hidden until a manual refresh. + val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys } + var nip29Relays by remember { mutableStateOf(emptySet()) } + LaunchedEffect(candidateRelays) { + val supported = nip29Relays.toMutableSet() + candidateRelays.forEach { relay -> + Amethyst.instance.nip11Cache.loadRelayInfo( + relay = relay, + onInfo = { info -> + if (info.supported_nips?.any { it == "29" } == true && supported.add(relay)) { + nip29Relays = supported.toSet() + } + }, + onError = { _, _, _ -> }, + ) + } + } + + LaunchedEffect(listName, perRelay, joinedGroups, nip29Relays) { feedContentState.checkKeysInvalidateDataAndSendToTop() } } 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 index 0fa46660e3..8d60d372a9 100644 --- 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 @@ -27,6 +27,7 @@ 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.relayAdvertisesNip29 import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -104,7 +105,19 @@ class RelayGroupDiscoveryFeedFilter( joined: Set, ): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember() - private fun constraints(): Map = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() + /** + * The per-relay constraints, restricted to relays that ADVERTISE NIP-29 in their NIP-11 + * `supported_nips`. A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a + * relay every 39000 is relay-signed and genuine; general relays (nostr.wine, etc.) instead store + * stray user-published 39000s that have no roster and can't be joined. Dropping non-advertising + * relays here filters out that noise for both the match test and the REQ-driven feed at one + * point. Relays whose NIP-11 hasn't loaded yet resolve to false and are warmed by the screen + * ([WatchAccountForRelayGroupDiscovery]), which re-invalidates the feed once support is known. + */ + private fun constraints(): Map = + account.liveRelayGroupsDiscoveryFollowListsPerRelay.value + .toGroupConstraints() + .filterKeys { relayAdvertisesNip29(it) } override fun feed(): List { if (isMine()) return sort(myGroupNotes()) From 0f01dc0c12e0ec63ef78cfb2af81cb0fff5ed4fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:18:13 +0000 Subject: [PATCH 75/81] feat: verify NIP-29 group metadata is signed by the relay's own key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-29 metadata/roster events (39000-39003) "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 test for a genuine group is `39000.author == relay.self` — which also rejects a stray user-published 39000 even on a real NIP-29 relay, something the earlier supported_nips heuristic could not. Add `isRelaySignedRelayGroup(channel)`: strict `author == self` when the relay publishes `self`, falling back to `supported_nips ∋ 29` when it omits `self`, and false when it has neither. Apply it at the surfaces that show unsolicited groups: - Discovery feed: replace the relay-level supported_nips filter with the per-channel self-key check in matches(); the screen now warms each candidate relay's NIP-11 and re-invalidates the feed as each doc resolves. - On-relay group list: filter to relay-signed groups, warming that relay's NIP-11 so genuine groups fill in and fakes stay hidden. - CLI `relaygroup browse`/`info`: fetch the relay's NIP-11 (new Context.relayInfo) and drop 39xxx not signed by `self`; browse reports the dropped count. Explicit user actions (a received invite link, opening an naddr) are left untouched — hiding those would be user-hostile. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../model/nip11RelayInfo/RelaySupportsNip.kt | 29 ++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 12 ++++++- .../relayGroup/RelayGroupDiscoveryScreen.kt | 30 ++++++++-------- .../dal/RelayGroupDiscoveryFeedFilter.kt | 20 ++++------- .../com/vitorpamplona/amethyst/cli/Context.kt | 25 ++++++++++++++ .../cli/commands/RelayGroupReadCommands.kt | 34 +++++++++++++++++-- 6 files changed, 119 insertions(+), 31 deletions(-) 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 index a3e5020d41..779efc2079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -21,7 +21,9 @@ 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. @@ -40,3 +42,30 @@ fun relayAdvertisesNip( /** 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/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index b1c16cf924..6821e1ba79 100644 --- 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 @@ -54,6 +54,8 @@ 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 @@ -88,11 +90,15 @@ fun RelayGroupChannelListScreen( 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 channels by produceState( + val allChannels by produceState( initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }, relay, ) { @@ -103,6 +109,10 @@ fun RelayGroupChannelListScreen( } } + // 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( 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 index 405a65d4db..730239c0f6 100644 --- 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 @@ -219,31 +219,33 @@ private fun WatchAccountForRelayGroupDiscovery( val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList .collectAsStateWithLifecycle() - // Discovery only shows groups hosted on relays that actually run NIP-29 (a relay that does - // rejects user-authored 39xxx, so its 39000s are all genuine; general relays instead carry stray - // fake ones). The feed decides this from each relay's cached NIP-11 `supported_nips`, so warm - // the candidate relays here and re-invalidate as support is confirmed — otherwise a relay whose - // NIP-11 lands after its 39000s would stay hidden until a manual refresh. + // 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 the + // candidate relays here and re-invalidate as each doc resolves — otherwise a relay whose NIP-11 + // lands after its 39000s would stay hidden until a manual refresh. val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys } - var nip29Relays by remember { mutableStateOf(emptySet()) } + var nip11Loaded by remember { mutableStateOf(emptySet()) } LaunchedEffect(candidateRelays) { - val supported = nip29Relays.toMutableSet() + val loaded = nip11Loaded.toMutableSet() candidateRelays.forEach { relay -> Amethyst.instance.nip11Cache.loadRelayInfo( relay = relay, - onInfo = { info -> - if (info.supported_nips?.any { it == "29" } == true && supported.add(relay)) { - nip29Relays = supported.toSet() - } - }, - onError = { _, _, _ -> }, + onInfo = { if (loaded.add(relay)) nip11Loaded = loaded.toSet() }, + onError = { _, _, _ -> if (loaded.add(relay)) nip11Loaded = loaded.toSet() }, ) } } - LaunchedEffect(listName, perRelay, joinedGroups, nip29Relays) { + 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 when one lands. + LaunchedEffect(nip11Loaded) { + feedContentState.invalidateData() + } } /** 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 index 8d60d372a9..6a060f9806 100644 --- 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 @@ -27,7 +27,7 @@ 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.relayAdvertisesNip29 +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 @@ -105,19 +105,7 @@ class RelayGroupDiscoveryFeedFilter( joined: Set, ): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember() - /** - * The per-relay constraints, restricted to relays that ADVERTISE NIP-29 in their NIP-11 - * `supported_nips`. A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a - * relay every 39000 is relay-signed and genuine; general relays (nostr.wine, etc.) instead store - * stray user-published 39000s that have no roster and can't be joined. Dropping non-advertising - * relays here filters out that noise for both the match test and the REQ-driven feed at one - * point. Relays whose NIP-11 hasn't loaded yet resolve to false and are warmed by the screen - * ([WatchAccountForRelayGroupDiscovery]), which re-invalidates the feed once support is known. - */ - private fun constraints(): Map = - account.liveRelayGroupsDiscoveryFollowListsPerRelay.value - .toGroupConstraints() - .filterKeys { relayAdvertisesNip29(it) } + private fun constraints(): Map = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() override fun feed(): List { if (isMine()) return sort(myGroupNotes()) @@ -170,6 +158,10 @@ class RelayGroupDiscoveryFeedFilter( 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 } 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/commands/RelayGroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt index e03a885e37..af7441e3d4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt @@ -24,7 +24,9 @@ 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 @@ -88,19 +90,26 @@ object RelayGroupReadCommands { 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 metas = + 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), ), ) @@ -127,7 +136,13 @@ object RelayGroupReadCommands { tags = mapOf("d" to listOf(groupId)), limit = 10, ) - val events = ctx.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000).map { it.second } + 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() @@ -156,6 +171,21 @@ object RelayGroupReadCommands { } } + /** + * 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(), From fa0883f4a085cac10507a0e3aa628730ac6db12d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:43:20 +0000 Subject: [PATCH 76/81] perf: warm NIP-29 relays' NIP-11 in parallel + pre-warm joined groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay-signed group check reads each host relay's NIP-11 `self` from cache, but the discovery screen was warming those docs SERIALLY — Nip11Retriever awaits each HTTP fetch, so N relays meant N sequential round-trips and one slow or unreachable relay stalled every group behind it until its socket timeout. That's why groups trickled in. - Add WarmNip11(relays): fans the fetches out, one coroutine each, so the wait is the slowest single fetch instead of the sum. Discovery now uses it and re-invalidates the feed as each doc lands (via a version counter). - Pre-warm NIP-11 for joined groups' host relays from the Messages tab (WarmJoinedRelayGroupNip11), so by the time one surfaces in discovery the answer is a cache hit. NIP-11 stays cached for an hour. Note: the Messages tab itself never needed this — joined ("My Groups") rows are authoritative from the kind-10009 list and were never gated on NIP-11. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../model/nip11RelayInfo/LoadRelayInfo.kt | 32 ++++++++++++++ .../relayGroup/RelayGroupDiscoveryScreen.kt | 27 ++++-------- .../relayGroup/WarmJoinedRelayGroupNip11.kt | 44 +++++++++++++++++++ .../rooms/singlepane/MessagesSinglePane.kt | 5 +++ .../chats/rooms/twopane/MessagesTwoPane.kt | 5 +++ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/WarmJoinedRelayGroupNip11.kt 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/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index 730239c0f6..3b7ff378eb 100644 --- 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 @@ -42,7 +42,7 @@ 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.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -53,7 +53,6 @@ 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.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -61,6 +60,7 @@ 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 @@ -221,29 +221,20 @@ private fun WatchAccountForRelayGroupDiscovery( // 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 the - // candidate relays here and re-invalidate as each doc resolves — otherwise a relay whose NIP-11 - // lands after its 39000s would stay hidden until a manual refresh. + // 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. val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys } - var nip11Loaded by remember { mutableStateOf(emptySet()) } - LaunchedEffect(candidateRelays) { - val loaded = nip11Loaded.toMutableSet() - candidateRelays.forEach { relay -> - Amethyst.instance.nip11Cache.loadRelayInfo( - relay = relay, - onInfo = { if (loaded.add(relay)) nip11Loaded = loaded.toSet() }, - onError = { _, _, _ -> if (loaded.add(relay)) nip11Loaded = loaded.toSet() }, - ) - } - } + 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 when one lands. - LaunchedEffect(nip11Loaded) { + // match, not the key), so force a re-filter directly as each one lands. + LaunchedEffect(nip11Version) { feedContentState.invalidateData() } } 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/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 1f0bc4aef4..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 @@ -40,6 +40,7 @@ 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 @@ -105,6 +106,10 @@ fun MessagesSinglePane( // 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( 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 7f0a3c60a1..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,7 @@ 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 @@ -95,6 +96,10 @@ fun MessagesTwoPane( 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( From babdea67abab4c8835d8154fa213e7aa76543294 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:51:51 +0000 Subject: [PATCH 77/81] feat: list joined groups' relays as filter chips in Relay Groups discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You had to Favorite a relay (kind-10012) before it appeared as a chip in the Relay Groups top-nav filter — even for relays you already have groups on. Now the host relay of every group in your joined list (kind-10009) also shows up as a relay chip, so you can browse the other groups on those relays without favoriting them first. Added only to the relay-groups discovery catalog (not git/podcasts/etc.), deduped against relays already present as favorites. Selecting one resolves to that relay's groups (filtered by the relay-signed self-key check as usual). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/screen/TopNavFilterState.kt | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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 53e54b2b8b..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 @@ -332,16 +333,32 @@ class TopNavFilterState( combineTransform( livePeopleListsFlow, liveInterestFlows, - ) { peopleLists, interests -> + 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 a - // favorite-relay chip; "Mine" (the joined-groups view) sits last in the base - // group to match the ordering every other feed uses. + // 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(), ) From 4d81d298ec2d2f8dbdf9abe6aa6a46841f121c0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:08:21 +0000 Subject: [PATCH 78/81] fix: load follow-participated relay groups in All-Follows without Global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The All-Follows / Authors discovery filter sets resolve their relays via the outbox model (each follow's own publish relays), but a NIP-29 roster (kind-39001 admins / 39002 members) lives ONLY on the group's host relay. So a follow who is an admin or member of a group never surfaced in All-Follows until the user bounced through Global — which pulls the whole directory — and back. Additionally query the follows as `#p` against the group-host relays we already know about (joined via kind-10009 + favorited via kind-10012), minus the relays the outbox filter already covers, and re-assemble when either list changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../RelayGroupsDiscoverySubAssembler.kt | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) 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 index dde3f5469d..9bc0a409dc 100644 --- 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 @@ -23,11 +23,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay 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 @@ -46,14 +53,53 @@ class RelayGroupsDiscoverySubAssembler( since: SincePerRelayMap?, ): List { val feedSettings = key.followsPerRelay() + val defaultSince = key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtIfFilled() - return makeRelayGroupsDiscoveryFilter( - feedSettings, - since, - 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() @@ -99,6 +145,18 @@ class RelayGroupsDiscoverySubAssembler( } } }, + // 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) From af0e9c512e61bfc14810757f407d31eafe1826c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:15:28 +0000 Subject: [PATCH 79/81] fix: warm host-relay NIP-11 in discovery so All-Follows shows their groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the All-Follows roster fix: the discovery screen warmed NIP-11 only for the outbox `candidateRelays`, but a group's relay-signed 39000 lives on its HOST relay. In All-Follows the subassembler now probes those host relays (joined kind-10009 + favorited kind-10012) for follow rosters, so their 39000s land in the cache — but the self-key gate (isRelaySignedRelayGroup) then read an unwarmed, empty NIP-11 (self=null) and dropped every one of them. That is why the groups only appeared after bouncing through Global (whose relay set happens to include the host relay, warming it as a side effect). Warm the joined + favorited host relays alongside the outbox candidates so the gate is a cache hit with the relay's `self` key present. Verified against wss://basspistol.org: NIP-11 advertises nip-29 with self=afd7da3f…, all six 39000s are signed by that self key, and `amy relaygroup browse` returns all six with unverified_dropped=0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupDiscoveryScreen.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 index 3b7ff378eb..d1f17c82f1 100644 --- 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 @@ -87,6 +87,7 @@ 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 /** @@ -218,13 +219,30 @@ private fun WatchAccountForRelayGroupDiscovery( // (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. - val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys } + // + // 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++ } From 003142122b42754181bce495ddd61f66cbf24e67 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:27:02 +0000 Subject: [PATCH 80/81] fix: don't drop NIP-11 waiters on a stuck Loading marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay-signed NIP-29 gate reads each relay's NIP-11 `self` key from the cache. Nip11CachedRetriever.loadRelayInfo treated a valid `Loading` marker as "a fetch is already in flight, just wait" — but it dropped the caller's callback entirely and had no way to notify it when the fetch finished. That is a lost-wakeup: if the coroutine that started the fetch is cancelled mid-flight (e.g. the discovery screen that launched the warm-up leaves composition when you tap a relay chip), the `Loading` marker is left behind, valid for a full hour. The relay's on-group-list screen then mounts, sees the stuck `Loading`, waits forever, and never receives the doc — so its NIP-11 looks empty (no `self`), the self-key gate rejects every 39000, and a group you can plainly see under the "Mine" filter (which bypasses the gate) vanishes on the relay page. Re-fetch on `Loading` instead of silently waiting: the fetch is cheap, dedups at the HTTP layer, and guarantees this caller is notified. Error caching is unchanged (still avoids hammering a genuinely broken relay). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../model/nip11RelayInfo/Nip11CachedRetriever.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 -> { From f946e1aaf4a446ab7dbf9668d2c2c04d7bfeaae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:50:23 +0000 Subject: [PATCH 81/81] fix: use the Topic icon for the composer's subject toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subject/title toggle used the Article (document-lines) glyph, which reads as "body text". Switch to Topic — a clearer signifier for a subject/title line. Codepoint already present in MaterialSymbols, so no font-subset regen needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 184913312a..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 @@ -916,7 +916,7 @@ private fun AddSubjectButton( ) { IconButton(onClick = onClick) { Icon( - symbol = MaterialSymbols.AutoMirrored.Article, + 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,