mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3514 from vitorpamplona/claude/armada-nip29-integration-lwqard
Relay Groups: NIP-29 relay-based group chat (Armada interop)
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
# NIP-29 Relay-Based Groups — Deep Study of Armada + Amethyst Integration Plan
|
||||
|
||||
**Date:** 2026-07-07
|
||||
**Status:** Research / design study (no code yet)
|
||||
**Reference client studied:** [Armada](https://gitlab.com/soapbox-pub/armada) (Soapbox), commit at HEAD of `main`
|
||||
**Spec:** [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md)
|
||||
|
||||
This document is a deep study of how **Armada** — a Discord-style NIP-29 client
|
||||
by Soapbox — creates events, manages chat, displays information, and handles
|
||||
invites/joins/leaves, followed by a concrete plan for bringing NIP-29 into
|
||||
Amethyst. It is written to answer "how does relay-based group chat actually work
|
||||
in a shipping client, and where does it slot into Amethyst's existing chat
|
||||
stack."
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR for the impatient
|
||||
|
||||
- **NIP-29 groups live on ONE relay.** A group is addressed by the pair
|
||||
`(relayUrl, groupId)`. The relay is the source of truth — it signs the group's
|
||||
metadata/membership/roles and enforces who may write. This is fundamentally
|
||||
different from Nostr's usual "publish everywhere" model.
|
||||
- **Armada models it as Discord:** a **server = a relay**, a **channel = a
|
||||
NIP-29 group**. The far-left rail is a list of relays; picking one shows that
|
||||
relay's channels; picking a channel shows its kind-9 message timeline.
|
||||
- **The user's home base is kind 10009** (NIP-51 "simple groups list"): it stores
|
||||
both the relays they've added (`r` tags) and the groups they've joined
|
||||
(`group` tags), privately (NIP-44 encrypted to self).
|
||||
- **Amethyst is already ~70% wired for this at the protocol layer.** Quartz has a
|
||||
`nip29RelayGroups` package (metadata/moderation/request events) **and** a
|
||||
`nip43RelayMembers` package (the relay-level membership handshake Armada uses).
|
||||
The main protocol gap is the **kind-9 group chat *message* event** itself.
|
||||
- **Amethyst also already has the perfect UX analog:** the `ephemChat` feature
|
||||
(NIP-C7, kind 23333) is a *relay-scoped* chat room identified by a
|
||||
`RoomId(id, relayUrl)` — the exact shape of a NIP-29 group address — with a
|
||||
full channel/screen/join/leave UI under
|
||||
`amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/`. NIP-29 is a
|
||||
managed, moderated sibling of that feature.
|
||||
|
||||
---
|
||||
|
||||
## 1. NIP-29 protocol primer (the parts that matter)
|
||||
|
||||
### 1.1 The addressing model
|
||||
|
||||
A group is **not** a global object. It exists on a specific relay, and its id is
|
||||
only unique *within that relay*. So every reference to a group is the pair:
|
||||
|
||||
```
|
||||
(relay websocket URL, group id)
|
||||
```
|
||||
|
||||
The group id is a short opaque string (Armada mints 8 random bytes → 16 hex
|
||||
chars). Everything that happens "in" the group is tagged with `["h", "<groupId>"]`
|
||||
and published **only** to that relay.
|
||||
|
||||
### 1.2 Event kinds
|
||||
|
||||
| Kind | Name | Signed by | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| **9** | group chat message | user | the actual chat line (has `["h", groupId]`) |
|
||||
| 11 | group thread/forum post | user | long-form/threaded root |
|
||||
| 1111 | NIP-22 comment | user | threaded reply to a chat message |
|
||||
| 7 | reaction | user | emoji reaction (scoped by `h`) |
|
||||
| 1068 | poll (NIP-88) | user | poll posted into the timeline |
|
||||
| **9000** | put-user | user (admin) | add member / set roles |
|
||||
| **9001** | remove-user | user (admin) | remove member |
|
||||
| **9002** | edit-metadata | user (admin) | change name/about/picture/flags |
|
||||
| 9005 | delete-event | user (admin) | moderator delete a message |
|
||||
| **9007** | create-group | user (admin) | create a new group |
|
||||
| 9008 | delete-group | user (admin) | delete a group |
|
||||
| **9009** | create-invite | user (admin) | mint an invite code |
|
||||
| **9021** | join-request | user | ask to join (optional `code`) |
|
||||
| **9022** | leave-request | user | leave the group |
|
||||
| **39000** | group metadata | **relay** | name, picture, about, flags (addressable, `d`=groupId) |
|
||||
| **39001** | group admins | **relay** | `["p", pubkey, role…]` list |
|
||||
| **39002** | group members | **relay** | `["p", pubkey]` list |
|
||||
| **39003** | group roles | **relay** | `["role", name, desc]` definitions |
|
||||
| 39004 | live AV participants | **relay** | LiveKit room presence (Armada extension use) |
|
||||
|
||||
The **9xxx** events are user-authored **requests/commands**; the relay validates
|
||||
the author's role and, if accepted, updates the group state and re-emits the
|
||||
**39xxx** relay-signed snapshots. **Clients read 39xxx, write 9xxx.**
|
||||
|
||||
### 1.3 Group metadata flags (kind 39000 tags)
|
||||
|
||||
`name`, `picture`, `about` carry display data. Boolean status is presence-based:
|
||||
|
||||
- `private` — only members can **read** (relay gates reads behind NIP-42 AUTH).
|
||||
- `restricted`* — only members can **write**.
|
||||
- `closed` — join requests are ignored; you need an invite code.
|
||||
- `hidden` — metadata hidden from non-members.
|
||||
- `livekit` — group has an AV room.
|
||||
- `supported_kinds` — whitelist of accepted kinds (absent = all).
|
||||
|
||||
The antonyms are `public` / `open` (Armada's `edit-metadata` emits the antonym
|
||||
tag to *clear* `private`/`closed`; see §2.4).
|
||||
|
||||
### 1.4 The `previous` tag (timeline references) — and why Armada drops it
|
||||
|
||||
NIP-29 defines an optional `["previous", <id-prefix>, …]` tag: each event should
|
||||
reference the first-8-chars of several of the group's recent events so a relay
|
||||
can reject events that were composed against a *forked* view of the timeline
|
||||
(anti-context-spam). **Armada deliberately does not emit it** — see the
|
||||
verbatim rationale in `ChatComposer.buildMessageTags`:
|
||||
|
||||
> relay29's `CheckPreviousTag` rejects any event whose first `previous` ref isn't
|
||||
> in the group's in-memory last-50 ring. We can only pick refs from a local (and
|
||||
> own-excluded) message snapshot, which routinely drifts out of that window —
|
||||
> especially when replying to older messages — causing the relay to silently drop
|
||||
> legitimate messages/replies. `previous` is optional in NIP-29 and only guards
|
||||
> against relay-fork attacks, which don't apply to this single-host-per-group
|
||||
> deployment.
|
||||
|
||||
**Takeaway for Amethyst:** Quartz *has* a `PreviousTag` class, but a
|
||||
single-host-per-group deployment does not need it, and emitting it naively causes
|
||||
silent drops. Start without it (like Armada); only add it if targeting a relay
|
||||
that enforces it.
|
||||
|
||||
---
|
||||
|
||||
## 2. How Armada creates events
|
||||
|
||||
Armada's whole NIP-29 protocol layer is ~770 lines in
|
||||
`client/src/lib/nip29.ts` (constants, parsers, tag builders), driven by a set of
|
||||
TanStack-Query hooks in `client/src/hooks/`. Every write goes through a single
|
||||
`useNostrPublish` mutation whose `relay` option **pins the event to the group's
|
||||
host relay** — this is the linchpin of the whole design.
|
||||
|
||||
### 2.1 The publish chokepoint (`useNostrPublish.ts`)
|
||||
|
||||
```ts
|
||||
// EventTemplate has an optional `relay?: string`.
|
||||
// When set, publish ONLY to this relay (NIP-29 group traffic must stay on
|
||||
// the group's host server). When omitted, the event goes to all app relays.
|
||||
if (relay) {
|
||||
await nostr.relay(relay).event(event, …); // single-relay send
|
||||
} else {
|
||||
await nostr.event(event, …); // fan out to app relays
|
||||
}
|
||||
```
|
||||
|
||||
It also: adds a NIP-89 `["client", APP_NAME]` tag, adds `published_at` for
|
||||
replaceable kinds, stores the signed event locally *before* the network call (so
|
||||
optimistic UI + offline retry work), and normalizes relay rejection strings for
|
||||
toasts (`relayRejectionMessage`).
|
||||
|
||||
**Every NIP-29 write in Armada passes `relay: relayUrl`.** Group/channel events
|
||||
*never* touch the general app relays. This is the single most important
|
||||
invariant to replicate.
|
||||
|
||||
### 2.2 Create a group (`CreateGroupDialog.tsx` + `useGroupModeration.ts`)
|
||||
|
||||
A "create channel" is a **two-event sequence**, then a bookkeeping write:
|
||||
|
||||
```ts
|
||||
// 1. kind 9007 create-group — just the h tag.
|
||||
await createGroup({ groupId });
|
||||
// → publishEvent({ kind: 9007, content: "", tags: [["h", groupId]], relay })
|
||||
|
||||
// 2. kind 9002 edit-metadata — name/about/visibility.
|
||||
await editMetadata.mutateAsync({ name, about, isPrivate, isClosed });
|
||||
// → tags: [["h", groupId], ["name", name], ["about", about],
|
||||
// [isPrivate ? "private" : "public"], [isClosed ? "closed" : "open"]]
|
||||
|
||||
// 3. remember it in the user's kind 10009 list (best-effort).
|
||||
updateList({ type: "add-group", ref: { id: groupId, relay: relayUrl } });
|
||||
```
|
||||
|
||||
The group id is client-minted (`crypto.getRandomValues(8)` → hex). The creator
|
||||
becomes admin automatically (the relay assigns the group-creator role). Then the
|
||||
UI navigates to `/s/<relayParam>/<groupId>`.
|
||||
|
||||
> Server note: Armada's own relay (`server/group.go`) **restricts kind 9007 to
|
||||
> configured admin pubkeys** — on that deployment only operators create channels.
|
||||
> That's a relay-policy choice, not a NIP-29 requirement.
|
||||
|
||||
### 2.3 Send a chat message (`ChatComposer.tsx`)
|
||||
|
||||
A message is **kind 9** with `["h", groupId]` plus standard Nostr tags. The tag
|
||||
builder (`buildMessageTags`) assembles:
|
||||
|
||||
- `["h", groupId]` — always first, required.
|
||||
- `["t", hashtag]` — extracted hashtags.
|
||||
- `["p", pubkey]` — NIP-27 mentions decoded from `nostr:npub…` in content.
|
||||
- NIP-10 marked reply tags when replying:
|
||||
`["e", rootId, relay, "root", rootAuthor]` + `["e", replyId, relay, "reply", replyAuthor]`.
|
||||
- `["q", …]` — NIP-18 quotes for embedded nevent/naddr.
|
||||
- NIP-30 custom emoji tags, NIP-92 `imeta` tags for uploads.
|
||||
- **No `previous` tag** (see §1.4).
|
||||
|
||||
Sending is **optimistic**: the event is signed locally, inserted into the
|
||||
timeline cache with a `pending` status, then sent. Because the id is computed at
|
||||
sign time, the relay's echo of the same event dedupes automatically against the
|
||||
optimistic copy (id match) and flips it to confirmed.
|
||||
|
||||
### 2.4 Edit metadata / moderation (`useGroupModeration.ts`)
|
||||
|
||||
All moderation is one small hook returning mutations, each a single pinned
|
||||
publish. Exact tag shapes:
|
||||
|
||||
| Action | kind | tags |
|
||||
|--------|------|------|
|
||||
| putUser | 9000 | `[["h",g], ["p", pubkey, ...roles]]` |
|
||||
| removeUser | 9001 | `[["h",g], ["p", pubkey]]` (content = reason) |
|
||||
| deleteEvent | 9005 | `[["h",g], ["e", eventId]]` |
|
||||
| editMetadata | 9002 | `[["h",g], ...metadataTags(patch)]` |
|
||||
| deleteGroup | 9008 | `[["h",g]]` |
|
||||
| createInvite | 9009 | `[["h",g], ["code", code]]` |
|
||||
|
||||
`metadataTags` emits antonym tags to clear flags (`public`/`open`) but only
|
||||
*asserts* `restricted`/`hidden` (no documented antonyms). After a successful
|
||||
moderation write, the relevant TanStack query keys are invalidated so the
|
||||
39xxx-derived views refetch.
|
||||
|
||||
### 2.5 Invites (`InvitePeopleDialog.tsx`)
|
||||
|
||||
Opening the invite dialog **immediately mints an invite and builds a link** — the
|
||||
"silly-easy part." Two-tier logic:
|
||||
|
||||
1. **Prefer a relay-level claim (NIP-43 / zooid).** `useRelayClaim` queries the
|
||||
relay for a `kind 28935` invite it issues to authed members; if present, its
|
||||
`claim` tag value is the invite code.
|
||||
2. **Fall back to a per-group NIP-29 code (kind 9009).** If the relay issues no
|
||||
claim, mint a random 6-byte code and publish `createInvite({ code })`.
|
||||
|
||||
The shareable URL is `…/s/<relayParam>/<groupId>?code=<code>`. Anyone opening it
|
||||
hits `GroupPage`, which auto-joins using the `code` query param.
|
||||
|
||||
### 2.6 Join / leave / membership (`useGroupMembership.ts`, `useRelayMembership.ts`)
|
||||
|
||||
**Join (kind 9021)** is a two-step handshake, in this order:
|
||||
|
||||
```ts
|
||||
// 1. Best-effort RELAY-level join first (zooid/Coracle relays gate ALL writes
|
||||
// behind relay membership, rejecting non-members before group join is even
|
||||
// considered). Ephemeral kind 28934 carrying the invite as a `claim` tag.
|
||||
// NEVER throws — no-ops on relays that don't implement it (e.g. relay29).
|
||||
await joinRelay({ relayUrl, claim: code });
|
||||
|
||||
// 2. The real NIP-29 group join. Same invite carried as a `code` tag.
|
||||
await publishEvent({ kind: 9021, content: reason, tags: [["h",g], ["code",code]], relay });
|
||||
// "already a member" rejection is treated as success.
|
||||
```
|
||||
|
||||
**Leave (kind 9022):** `publishEvent({ kind: 9022, tags: [["h",g]], relay })`.
|
||||
The relay auto-issues the corresponding 9001 remove-user.
|
||||
|
||||
**Membership state** is derived per NIP-29 by querying the *latest* of
|
||||
`{kinds:[9000,9001], "#h":[g], "#p":[me]}` on the host relay — 9000 latest ⇒
|
||||
member, 9001 latest ⇒ not. Polls every 30s; 15s stale time.
|
||||
|
||||
**Relay membership (NIP-43)** is its own layer, cleanly separated: `useRelayClaim`
|
||||
(fetch a 28935 claim), `useJoinRelay` (publish 28934 with the claim),
|
||||
`useLeaveRelay` (28936). All best-effort and non-throwing — the group join is the
|
||||
source of truth for the actual outcome.
|
||||
|
||||
---
|
||||
|
||||
## 3. How Armada displays information
|
||||
|
||||
### 3.1 The three-pane Discord layout
|
||||
|
||||
```
|
||||
┌────┬──────────────┬───────────────────────────┐
|
||||
│rail│ channel list │ message timeline │
|
||||
│ │ (this relay) │ + composer │
|
||||
│ 🟦 │ # general │ ...kind 9 messages... │
|
||||
│ 🟩 │ # random │ │
|
||||
│ ➕ │ # dev │ [type a message] │
|
||||
└────┴──────────────┴───────────────────────────┘
|
||||
servers channels chat
|
||||
=relays =NIP-29 groups =kind 9
|
||||
```
|
||||
|
||||
- **`ServerRail.tsx`** — the far-left vertical rail. Each icon is a **relay**
|
||||
(`{ kind: "server", url }`), fed by the pinned `PLATFORM_RELAYS` +
|
||||
`config.addedRelays`. Supports Discord-style drag-to-reorder and **folders**.
|
||||
(It also unifies in Concord E2EE communities, which are *not* NIP-29 — ignore
|
||||
those.) Server order is synced to the kind-10009 `r` tags.
|
||||
- **`ChannelSidebar`** — for the selected relay, lists its groups via
|
||||
`useRelayGroups(relayUrl)`.
|
||||
- **`GroupChat.tsx`** — the timeline + composer for the selected group.
|
||||
|
||||
Selecting a relay first, then a room, is *exactly* the "select the relay first
|
||||
and then pick the rooms in each relay" UX in the ask. Folders are Armada's answer
|
||||
to "organization to group rooms on" — but note they group **relays**, not rooms;
|
||||
rooms are grouped implicitly by their host relay.
|
||||
|
||||
### 3.2 Fetching a relay's channels (`useRelayGroups.ts`)
|
||||
|
||||
Lists a relay's groups by querying `{kinds:[39000]}` on that relay. Key
|
||||
subtleties:
|
||||
|
||||
- **Trust:** kind 39000 must be signed by the relay's own key. Armada reads the
|
||||
relay's `self`/`pubkey` from its NIP-11 doc and adds `authors:[relaySelf]` so
|
||||
**forged metadata from other publishers is never trusted**. Until NIP-11
|
||||
resolves it races a direct fetch (2s) then refetches once when the key lands.
|
||||
- **Hidden groups:** relays hide closed/private groups from open listings, so the
|
||||
ids the user *remembers* (their kind-10009 `group` tags for this relay) are
|
||||
queried explicitly by `#d` and merged in.
|
||||
- **Provenance scoping:** several relays can share a signing key (zooid ships a
|
||||
shared identity), so author-scoping alone bleeds channels across relays. Armada
|
||||
records *which relay actually served* each cached event ("provenance") and
|
||||
scopes the IndexedDB cache read by it. This is a real, painful edge case worth
|
||||
remembering.
|
||||
- **Cache-as-floor:** cached events are merged *under* live ones so a sparse/flaky
|
||||
relay read can only add, never clear the list. Long stale time (1h), no polling
|
||||
— channel metadata is the most stable thing in the app.
|
||||
|
||||
### 3.3 Fetching a group's roster (`useGroup.ts`)
|
||||
|
||||
One query pulls the newest of `{kinds:[39000,39001,39002,39003], "#d":[groupId]}`
|
||||
(optionally author-scoped to the relay key) and composes
|
||||
`{ group, admins, members, roles }` (newest event per kind wins). **Local-first:**
|
||||
the plaintext 39xxx events are mirrored to IndexedDB, so a previously-opened
|
||||
group renders its roster instantly, with a background relay refresh.
|
||||
|
||||
### 3.4 The message timeline (`useGroupMessages.ts`)
|
||||
|
||||
The most sophisticated hook. For `(relayUrl, groupId)`:
|
||||
|
||||
- **Timeline kinds:** kind 9 + kind 1068 (polls); live sub also watches kind 5
|
||||
(deletions).
|
||||
- **Local-first + snapshot-first paint:** seeds from a synchronous localStorage
|
||||
"last screenful" snapshot → IndexedDB store → background relay page, merged
|
||||
append-only so nothing already shown is dropped.
|
||||
- **Scroll-up pagination:** `loadOlder()` walks an `until` cursor with a
|
||||
gap-guard (Ditto's pattern) so a stale straggler doesn't leap the cursor past
|
||||
real history.
|
||||
- **Live subscription:** one `req` with a 5-minute `since` lookback (so a message
|
||||
that arrived via push before the group was opened still replays); dedupes by id;
|
||||
processes kind-5 deletions by dropping referenced ids.
|
||||
- **Optimistic send status** map (`pending`/`failed`) reconciled by relay echo.
|
||||
- **Resilience:** 60s backstop poll + refetch-on-focus/reconnect to heal
|
||||
half-dead mobile sockets where the live socket silently died.
|
||||
|
||||
### 3.5 Group discovery / "home" (`useUserGroupList.ts`, kind 10009)
|
||||
|
||||
The **cross-device source of truth** for a user's memberships is a single kind
|
||||
10009 event (NIP-51 "simple groups"):
|
||||
|
||||
- `group` tags `["group", id, relay]` — joined groups (with host relay).
|
||||
- `r` tags `["r", relayUrl]` — servers/relays in use.
|
||||
- Both stored as **NIP-44 private items** (encrypted to self in `.content`);
|
||||
read-modify-write via `useUpdateUserGroupList`. Armada persists the *decrypted*
|
||||
list to disk ("folded cache") so boot doesn't pay a signer round-trip, and
|
||||
refuses to write if it couldn't decrypt the prior list (avoids wiping it).
|
||||
|
||||
There is also a `useGroupSearch` (NIP-50 `search` scoped by `#h`, merged with the
|
||||
local timeline cache) for in-group message search.
|
||||
|
||||
### 3.6 What else rides on the `h` tag
|
||||
|
||||
Armada extends the group with several kinds, all scoped by `["h", groupId]` so
|
||||
the relay routes/authorizes them: **pins** (kind 39041, an Armada extension,
|
||||
addressable `d`=groupId, admin-only), **calendar events** (NIP-52
|
||||
31922/31923/31925), **reactions** (kind 7), **threaded replies** (kind 1111
|
||||
NIP-22 comments), and **webxdc mini-apps** (9450/24450). All follow the same
|
||||
pattern: `["h", groupId]` + pin to host relay. Useful precedent that "anything
|
||||
can be a group event if it carries `h` and the relay accepts the kind."
|
||||
|
||||
---
|
||||
|
||||
## 4. Design constraints & gotchas (the expensive lessons)
|
||||
|
||||
1. **Relay-scoping is absolute.** Group events go only to the host relay, and
|
||||
group queries hit only the host relay. Amethyst's relay client fans out to
|
||||
many relays by default — NIP-29 needs a *single-relay* send/subscribe path.
|
||||
2. **39xxx is relay-signed; trust it by the relay's own key.** Filter directory
|
||||
queries by `authors:[relayNip11Pubkey]`. Never trust group metadata otherwise.
|
||||
3. **Shared relay identities bleed groups.** If you cache 39000 by author only,
|
||||
two relays sharing a key cross-contaminate. Track per-relay provenance.
|
||||
4. **`previous` tags cause silent drops** unless you can guarantee refs are in the
|
||||
relay's last-50 ring. Omit them for single-host groups.
|
||||
5. **Two membership layers.** NIP-29 group membership (9000/9001) is distinct from
|
||||
relay-level membership (NIP-43, 28934/28935). Community relays (zooid) gate on
|
||||
the latter *first*. Do the relay handshake best-effort, treat the group join as
|
||||
authoritative.
|
||||
6. **Cache-as-floor everywhere.** A flaky relay returning nothing must never blank
|
||||
a channel list or roster. Merge cache under live, never overwrite.
|
||||
7. **Optimistic UI needs local signing.** Sign locally, insert with pending
|
||||
status, dedupe on relay echo by id.
|
||||
|
||||
---
|
||||
|
||||
## 5. Amethyst integration plan
|
||||
|
||||
### 5.1 What already exists (survey)
|
||||
|
||||
**Protocol (Quartz) — largely present.** `quartz/.../nip29RelayGroups/` already
|
||||
has:
|
||||
|
||||
- `metadata/` — `GroupMetadataEvent` (kind **39000**), `GroupAdminsEvent`
|
||||
(39001), `GroupMembersEvent` (39002), `SupportedRolesEvent` (39003).
|
||||
- `moderation/` — `CreateGroupEvent` (9007), `EditMetadataEvent` (9002),
|
||||
`PutUserEvent` (9000), `RemoveUserEvent` (9001), `DeleteEventEvent` (9005),
|
||||
`DeleteGroupEvent` (9008), `CreateInviteEvent` (9009), plus tag helpers.
|
||||
- `request/` — `JoinRequestEvent` (9021), `LeaveRequestEvent` (9022).
|
||||
- `tags/` — `GroupIdTag` (the `h` tag), `CodeTag`, `GroupAdminTag`, `RoleTag`,
|
||||
and even a `PreviousTag`.
|
||||
|
||||
**Relay membership (Quartz) — present.** `quartz/.../nip43RelayMembers/` has the
|
||||
full NIP-43 handshake Armada calls "relay membership": `RelayJoinRequestEvent`,
|
||||
`RelayInviteRequestEvent`, `RelayAddMemberEvent`, `RelayLeaveRequestEvent`,
|
||||
`RelayMembershipListEvent`, `ClaimTag`, `MemberTag`. There's even an
|
||||
`amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`.
|
||||
|
||||
**The UX analog (commons + amethyst) — present and close.** The `ephemChat`
|
||||
feature (NIP-C7, kind **23333**) is a *relay-scoped* chat room:
|
||||
|
||||
- `quartz/.../experimental/ephemChat/chat/EphemeralChatEvent.kt` — kind 23333,
|
||||
with `RoomId(room, relayUrl)` — **the same `(relay, id)` shape as a NIP-29
|
||||
address.**
|
||||
- `commons/.../model/emphChat/EphemeralChatChannel.kt` — `EphemeralChatChannel`
|
||||
with `relays() = setOf(roomId.relayUrl)`, wired into `LocalCache`, `Account`,
|
||||
`Note`, plus an `EphemeralChatListState`.
|
||||
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/` — a full UI:
|
||||
`EphemeralChatScreen`, `LoadEphemeralChatChannel`, `EphemeralChatChannelHeader`,
|
||||
`JoinChatButton`/`LeaveChatButton`, a `NewEphemeralChatScreen`, and datasource
|
||||
sub-assemblers (`FilterMessagesToEphemeralChat`, etc.).
|
||||
|
||||
So the Messages/Chats screen already hosts **four** conversation types:
|
||||
NIP-04/17 DMs, NIP-28 public channels, and NIP-C7 ephemeral chats — all under
|
||||
`chats/`. **NIP-29 groups become the fourth sibling.**
|
||||
|
||||
### 5.2 The main protocol gap
|
||||
|
||||
There is **no kind-9 group chat message event** in `nip29RelayGroups/`
|
||||
(`CreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9).
|
||||
The 9xxx moderation, 39xxx metadata, and 9021/9022 request events exist, but the
|
||||
actual message carrier does not. This is the first thing to build:
|
||||
|
||||
- `nip29RelayGroups/chat/GroupChatEvent.kt` — kind 9, `["h", groupId]` required,
|
||||
NIP-10 reply markers, NIP-27 mentions, NIP-92 imeta, NIP-30 emoji — mirror
|
||||
`ChannelMessageEvent` (NIP-28) which already does all of this, but swap the
|
||||
channel `e`-root tag for the `h` group tag. Optionally kind 11 (thread) and
|
||||
reuse NIP-22 `CommentEvent` for replies.
|
||||
|
||||
Also verify the existing 39000–39003 parsers expose the flag tags
|
||||
(`private`/`closed`/`restricted`/`hidden`/`livekit`/`supported_kinds`) and roles
|
||||
per §1.3; extend if not.
|
||||
|
||||
### 5.3 Recommended architecture mapping
|
||||
|
||||
| Armada (React) | Amethyst target | Notes |
|
||||
|----------------|-----------------|-------|
|
||||
| `lib/nip29.ts` | `quartz/.../nip29RelayGroups/` | mostly exists; add kind-9 `GroupChatEvent` + any missing parsers |
|
||||
| `useNostrPublish({relay})` | a single-relay send in `commons/.../relayClient/` | **critical new capability**: publish/subscribe pinned to one relay |
|
||||
| `useRelayGroups` | a `RelayGroupsState` / filter assembler | query 39000 on one relay, author-scoped to its NIP-11 key |
|
||||
| `useGroup` | `GroupChannel` model + roster state | compose newest 39000–39003; mirror `EphemeralChatChannel` |
|
||||
| `useGroupMessages` | a NIP-29 `FeedFilter` + `FeedContentState` | reuse `chats/publicChannels/datasource` sub-assembler pattern |
|
||||
| `useUserGroupList` (10009) | an `Account` state object (like `ephemeralChatListState`) | NIP-44 private items; StateFlow of joined groups + servers |
|
||||
| `useGroupMembership` | membership derivation from 9000/9001 | latest-wins per NIP-29 |
|
||||
| `useRelayMembership` (NIP-43) | already in `nip43RelayMembers` + `RelayMembersScreen` | best-effort handshake before join |
|
||||
| `ServerRail` + `ChannelSidebar` | Android: a relay picker → channel list inside the Chats tab | see §5.4 |
|
||||
| `GroupChat` + `ChatComposer` | reuse the ephemChat/NIP-28 chat screen + composer | swap the datasource + send to kind 9 + `h` |
|
||||
|
||||
**Placement per CLAUDE.md:** protocol → `quartz/`; the group model, list state,
|
||||
membership derivation, ViewModels/filters → `commons/` (so Desktop + CLI share);
|
||||
screen composables + navigation → `amethyst/` (bottom-nav) and `desktopApp/`
|
||||
(sidebar). The ephemChat feature is the template to copy for all three layers.
|
||||
|
||||
### 5.4 Recommended Amethyst UX
|
||||
|
||||
The ask floats three options; the study points to a clear answer:
|
||||
|
||||
- **Not** a flat list of rooms mixed into the DM inbox. NIP-29 rooms are
|
||||
relay-scoped and there can be many per relay — mixing them into the DM room
|
||||
list loses the relay grouping and doesn't scale.
|
||||
- **Yes** to "select the relay first, then pick rooms in that relay." This is
|
||||
Armada's model and it matches the protocol's addressing exactly. On Android
|
||||
(bottom-nav, no room for a permanent Discord rail), the natural shape is:
|
||||
- A **"Groups"/"Servers" entry inside the existing Chats tab** (alongside DMs,
|
||||
Public Chats, Ephemeral Chats).
|
||||
- Level 1: **your relays** (from kind-10009 `r` tags) — an "add relay" affordance
|
||||
and each row shows unread rollup.
|
||||
- Level 2: tap a relay → **its channels** (from `useRelayGroups`-equivalent),
|
||||
with a create-channel action (subject to relay policy).
|
||||
- Level 3: tap a channel → the **existing chat screen**, re-pointed at a NIP-29
|
||||
kind-9 datasource.
|
||||
- "Organization to group rooms on" = the **relay is the grouping**; add
|
||||
Discord-style relay folders later if desired (Armada's `railLayout`).
|
||||
- Desktop can render the true three-pane rail (it already uses a sidebar shell).
|
||||
|
||||
Deep-linking: adopt Armada's invite-link idea via Nostr-native addressing —
|
||||
`naddr` to the kind-39000 (kind + relay-key author + `d`=groupId + relay hint),
|
||||
plus an optional invite `code`. Amethyst already resolves `naddr`; a group `naddr`
|
||||
should route into the channel and, if a code is present, fire a 9021 join.
|
||||
|
||||
### 5.5 Suggested build order
|
||||
|
||||
1. **Quartz:** add `GroupChatEvent` (kind 9) + builders; confirm 39000 flag/role
|
||||
parsing. Unit-test against Armada-produced events (spin up `./start.sh` or use
|
||||
`chat.soapbox.pub`).
|
||||
2. **Relay client:** add a single-relay pinned publish + subscription path
|
||||
(the `relay: relayUrl` equivalent). This unblocks everything else.
|
||||
3. **commons:** `GroupChannel` model + `UserGroupListState` on `Account` (kind
|
||||
10009, mirror `EphemeralChatListState`) + membership derivation.
|
||||
4. **commons:** NIP-29 message `FeedFilter`/`FeedContentState` (copy the
|
||||
ephemChat/NIP-28 sub-assembler; timeline kinds 9 + 1068 + 5).
|
||||
5. **amethyst:** relay-picker → channel-list screens in the Chats tab; re-point
|
||||
the chat screen/composer at the kind-9 datasource; join/leave/create/invite
|
||||
dialogs (reuse `nip43RelayMembers` for the relay handshake).
|
||||
6. **Later:** roles/moderation UI, pins, reactions, threads, calendar, LiveKit AV.
|
||||
|
||||
### 5.6 Explicitly out of scope
|
||||
|
||||
Armada's `concord-v1`/`concord-v2` directories are a **separate** end-to-end
|
||||
encrypted community protocol (sealed envelopes, rekeying) — *not* NIP-29. They
|
||||
share the chat *components* via a `ChatTransport` abstraction but nothing else.
|
||||
Ignore them for NIP-29. (The `ChatTransport` pattern — a presentational chat UI
|
||||
fed by a capability interface — is itself a nice idea worth borrowing so DMs,
|
||||
NIP-28, ephemeral, and NIP-29 all render through one component.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Key file references
|
||||
|
||||
**Armada (studied):**
|
||||
- `client/src/lib/nip29.ts` — constants, parsers, tag builders
|
||||
- `client/src/hooks/useNostrPublish.ts` — the `relay`-pinned publish chokepoint
|
||||
- `client/src/hooks/useGroupModeration.ts` — 9000/9001/9002/9005/9007/9008/9009
|
||||
- `client/src/hooks/useGroupMembership.ts` + `useRelayMembership.ts` — 9021/9022 + NIP-43
|
||||
- `client/src/hooks/useRelayGroups.ts` / `useGroup.ts` / `useGroupMessages.ts` — display
|
||||
- `client/src/hooks/useUserGroupList.ts` — kind 10009 home base
|
||||
- `client/src/components/layout/ServerRail.tsx` — Discord rail (relay = server)
|
||||
- `client/src/components/chat/ChatComposer.tsx` — kind-9 send + the `previous`-tag rationale
|
||||
- `client/src/components/dialogs/{CreateGroup,InvitePeople}Dialog.tsx`
|
||||
- `server/group.go`, `server/invites.go`, `server/unmanaged.go` — relay29 policy
|
||||
|
||||
**Amethyst (integration surface):**
|
||||
- `quartz/.../nip29RelayGroups/**` — existing protocol events (add kind-9 chat)
|
||||
- `quartz/.../nip43RelayMembers/**` — relay membership handshake
|
||||
- `quartz/.../experimental/ephemChat/**` + `commons/.../model/emphChat/**` — the relay-scoped chat analog
|
||||
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/**` — UI template
|
||||
- `amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`
|
||||
- `quartz/.../nip28PublicChat/message/ChannelMessageEvent.kt` — the kind-9 builder to mirror
|
||||
@@ -26,6 +26,7 @@ import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.core.content.edit
|
||||
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
@@ -59,6 +60,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
@@ -106,6 +108,7 @@ private object PrefKeys {
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
|
||||
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
|
||||
const val DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST = "defaultRelayGroupsDiscoveryFollowList"
|
||||
const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList"
|
||||
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
|
||||
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
|
||||
@@ -150,6 +153,7 @@ private object PrefKeys {
|
||||
const val LATEST_HASHTAG_LIST = "latestHashtagList"
|
||||
const val LATEST_GEOHASH_LIST = "latestGeohashList"
|
||||
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
|
||||
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
|
||||
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
|
||||
const val CALLS_ENABLED = "calls_enabled"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
@@ -157,6 +161,7 @@ private object PrefKeys {
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
|
||||
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
|
||||
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
|
||||
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
|
||||
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
|
||||
|
||||
@@ -426,6 +431,7 @@ object LocalPreferences {
|
||||
|
||||
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultRelayGroupsDiscoveryFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
|
||||
@@ -498,6 +504,7 @@ object LocalPreferences {
|
||||
putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList)
|
||||
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
|
||||
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
|
||||
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
|
||||
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
|
||||
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
|
||||
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
|
||||
@@ -509,6 +516,7 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
|
||||
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
|
||||
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
|
||||
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
|
||||
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
|
||||
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
|
||||
// Any account that reaches a save has its notification filter in its
|
||||
@@ -629,6 +637,7 @@ object LocalPreferences {
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.IF_IN_MY_LIST
|
||||
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
|
||||
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
|
||||
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
@@ -663,6 +672,7 @@ object LocalPreferences {
|
||||
val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null)
|
||||
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
|
||||
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
|
||||
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
|
||||
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
|
||||
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
|
||||
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
|
||||
@@ -722,6 +732,7 @@ object LocalPreferences {
|
||||
val latestHashtagList = async { parseEventOrNull<HashtagListEvent>(latestHashtagListStr) }
|
||||
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
|
||||
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
|
||||
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
|
||||
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
|
||||
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
|
||||
val latestCashuWallet =
|
||||
@@ -773,6 +784,7 @@ object LocalPreferences {
|
||||
val latestHashtagListResolved = latestHashtagList.await()
|
||||
val latestGeohashListResolved = latestGeohashList.await()
|
||||
val latestEphemeralListResolved = latestEphemeralList.await()
|
||||
val latestRelayGroupListResolved = latestRelayGroupList.await()
|
||||
val latestTrustProviderListResolved = latestTrustProviderList.await()
|
||||
val latestPaymentTargetsResolved = latestPaymentTargets.await()
|
||||
val latestCashuWalletResolved = latestCashuWallet.await()
|
||||
@@ -796,6 +808,7 @@ object LocalPreferences {
|
||||
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
|
||||
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
|
||||
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
|
||||
defaultRelayGroupsDiscoveryFollowList = MutableStateFlow(followListPrefs.relayGroupsDiscovery),
|
||||
defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets),
|
||||
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
|
||||
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
|
||||
@@ -833,6 +846,7 @@ object LocalPreferences {
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
|
||||
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
|
||||
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
|
||||
backupUserMetadata = latestUserMetadataResolved,
|
||||
@@ -852,6 +866,7 @@ object LocalPreferences {
|
||||
backupHashtagList = latestHashtagListResolved,
|
||||
backupGeohashList = latestGeohashListResolved,
|
||||
backupEphemeralChatList = latestEphemeralListResolved,
|
||||
backupRelayGroupList = latestRelayGroupListResolved,
|
||||
backupTrustProviderList = latestTrustProviderListResolved,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
@@ -890,6 +905,7 @@ object LocalPreferences {
|
||||
val discovery: TopFilter,
|
||||
val polls: TopFilter,
|
||||
val pictures: TopFilter,
|
||||
val relayGroupsDiscovery: TopFilter,
|
||||
val napplets: TopFilter,
|
||||
val nsites: TopFilter,
|
||||
val workouts: TopFilter,
|
||||
@@ -946,6 +962,7 @@ object LocalPreferences {
|
||||
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
|
||||
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine),
|
||||
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
|
||||
@@ -34,6 +34,9 @@ import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
|
||||
@@ -199,6 +202,16 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
|
||||
@@ -250,6 +263,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
@@ -365,6 +379,9 @@ class Account(
|
||||
val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer)
|
||||
val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings)
|
||||
|
||||
val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer)
|
||||
val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings)
|
||||
|
||||
val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer)
|
||||
val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings)
|
||||
|
||||
@@ -539,6 +556,9 @@ class Account(
|
||||
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
|
||||
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
|
||||
|
||||
val liveRelayGroupsDiscoveryFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultRelayGroupsDiscoveryFollowList)
|
||||
val liveRelayGroupsDiscoveryFollowListsPerRelay = OutboxLoaderState(liveRelayGroupsDiscoveryFollowLists, cache, scope).flow
|
||||
|
||||
val liveNappletsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNappletsFollowList)
|
||||
val liveNappletsFollowListsPerRelay = OutboxLoaderState(liveNappletsFollowLists, cache, scope).flow
|
||||
|
||||
@@ -1445,6 +1465,154 @@ class Account(
|
||||
|
||||
suspend fun unfollow(channel: EphemeralChatChannel) = sendMyPublicAndPrivateOutbox(ephemeralChatList.unfollow(channel))
|
||||
|
||||
suspend fun follow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.follow(channel))
|
||||
|
||||
suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel))
|
||||
|
||||
// ── NIP-29 relay-group actions ───────────────────────────────────────────
|
||||
// All group commands are published ONLY to the group's host relay, where
|
||||
// relay29 authorizes them. The relay is the source of truth; the kind-10009
|
||||
// list is our own cross-device bookkeeping of what we joined.
|
||||
|
||||
/** Send a kind 9021 join request to the group's host relay and remember it. */
|
||||
suspend fun joinRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
code: String? = null,
|
||||
) {
|
||||
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
follow(channel)
|
||||
}
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
unfollow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
|
||||
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
|
||||
* the new group's id.
|
||||
*/
|
||||
suspend fun createRelayGroup(
|
||||
relay: NormalizedRelayUrl,
|
||||
groupId: String,
|
||||
name: String,
|
||||
about: String? = null,
|
||||
picture: String? = null,
|
||||
isPrivate: Boolean = false,
|
||||
isClosed: Boolean = false,
|
||||
isHidden: Boolean = false,
|
||||
isRestricted: Boolean = false,
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
): GroupId {
|
||||
signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) }
|
||||
|
||||
val edit =
|
||||
EditMetadataEvent.build(
|
||||
groupId,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
)
|
||||
signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
|
||||
|
||||
val id = GroupId(groupId, relay)
|
||||
follow(LocalCache.getOrCreateRelayGroupChannel(id))
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are
|
||||
* presence-only — public/open/visible/unrestricted are simply the ABSENCE of their
|
||||
* restrictive counterpart — so only the enabled restrictive flags are added.
|
||||
*/
|
||||
private fun relayGroupStatus(
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
): Set<GroupMetadataEvent.GroupStatus> =
|
||||
buildSet {
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN)
|
||||
if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
|
||||
}
|
||||
|
||||
/** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */
|
||||
suspend fun postRelayGroupThread(
|
||||
channel: RelayGroupChannel,
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
val template = ThreadEvent.build(body, title) { hTag(channel.groupId.id) }
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Mint a kind 9009 invite code for the group (admin/moderator only). */
|
||||
suspend fun createRelayGroupInvite(
|
||||
channel: RelayGroupChannel,
|
||||
code: String,
|
||||
) {
|
||||
val template = CreateInviteEvent.build(channel.groupId.id, code)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
|
||||
suspend fun removeRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
) {
|
||||
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
|
||||
* event (moderator only). Pass an empty [roles] list for a plain member.
|
||||
*/
|
||||
suspend fun putRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
roles: List<String>,
|
||||
) {
|
||||
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles))
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */
|
||||
suspend fun editRelayGroupMetadata(
|
||||
channel: RelayGroupChannel,
|
||||
name: String?,
|
||||
about: String?,
|
||||
picture: String?,
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
) {
|
||||
val template =
|
||||
EditMetadataEvent.build(
|
||||
channel.groupId.id,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community))
|
||||
|
||||
suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community))
|
||||
|
||||
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
|
||||
@@ -58,6 +60,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -212,6 +215,7 @@ class AccountSettings(
|
||||
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
|
||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
|
||||
@@ -242,6 +246,7 @@ class AccountSettings(
|
||||
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
|
||||
var backupGeohashList: GeohashListEvent? = null,
|
||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||
var backupRelayGroupList: SimpleGroupListEvent? = null,
|
||||
var backupTrustProviderList: TrustProviderListEvent? = null,
|
||||
var backupCashuWallet: CashuWalletEvent? = null,
|
||||
var backupNutzapInfo: NutzapInfoEvent? = null,
|
||||
@@ -269,7 +274,9 @@ class AccountSettings(
|
||||
var callMaxBitrateBps: Int = 1_500_000,
|
||||
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
|
||||
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
|
||||
) : EphemeralChatRepository,
|
||||
RelayGroupRepository,
|
||||
PublicChatListRepository {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
|
||||
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
|
||||
@@ -284,6 +291,13 @@ class AccountSettings(
|
||||
|
||||
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
|
||||
|
||||
fun updateRelayGroupViewMode(mode: RelayGroupViewMode) {
|
||||
if (relayGroupViewMode.value != mode) {
|
||||
relayGroupViewMode.tryEmit(mode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Always-on Notification Service
|
||||
// ---
|
||||
@@ -645,6 +659,17 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultRelayGroupsDiscoveryFollowList(name: FeedDefinition) {
|
||||
changeDefaultRelayGroupsDiscoveryFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultRelayGroupsDiscoveryFollowList(name: TopFilter) {
|
||||
if (defaultRelayGroupsDiscoveryFollowList.value != name) {
|
||||
defaultRelayGroupsDiscoveryFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
|
||||
changeDefaultNappletsFollowList(name.code)
|
||||
}
|
||||
@@ -1213,6 +1238,20 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
override fun relayGroupList() = backupRelayGroupList
|
||||
|
||||
override fun updateRelayGroupListTo(newRelayGroupList: SimpleGroupListEvent?) {
|
||||
// Joined groups can live in the NIP-44 private items (encrypted content),
|
||||
// so an empty `tags` is NOT an empty list — guard only on null.
|
||||
if (newRelayGroupList == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupRelayGroupList?.id != newRelayGroupList.id) {
|
||||
backupRelayGroupList = newRelayGroupList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
|
||||
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
|
||||
@@ -152,6 +153,22 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
@@ -195,6 +212,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
@@ -253,6 +271,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
@@ -334,6 +353,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val publicChatChannels = LargeCache<HexKey, PublicChatChannel>()
|
||||
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
|
||||
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
|
||||
val relayGroupChannels = LargeCache<GroupId, RelayGroupChannel>()
|
||||
|
||||
val paymentTracker = NwcPaymentTracker()
|
||||
|
||||
@@ -607,6 +627,11 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key)
|
||||
|
||||
fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key)
|
||||
|
||||
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
|
||||
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
|
||||
|
||||
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
|
||||
|
||||
fun getNoteIfExists(event: Event): Note? =
|
||||
@@ -695,6 +720,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) }
|
||||
|
||||
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
|
||||
|
||||
fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? {
|
||||
if (isValidHex(key)) {
|
||||
return getOrCreatePublicChatChannel(key)
|
||||
@@ -1722,6 +1749,118 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable
|
||||
* note and used to populate the [RelayGroupChannel]'s name/picture/about/
|
||||
* flags. The group is keyed by (host relay + group id): unlike NIP-C7, a
|
||||
* NIP-29 event does not carry its host relay in a tag — the host is the relay
|
||||
* that served it — so the channel can only be associated when we know the
|
||||
* serving relay (provenance). With no relay we still store the metadata.
|
||||
*/
|
||||
fun consume(
|
||||
event: GroupMetadataEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
if (relay != null) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay))
|
||||
(note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) }
|
||||
}
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
/** NIP-29 relay-signed member list (kind 39002) → the group's roster. */
|
||||
fun consume(
|
||||
event: GroupMembersEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
if (relay != null) {
|
||||
val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent
|
||||
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) }
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
/** NIP-29 relay-signed admin list (kind 39001) → the group's roster. */
|
||||
fun consume(
|
||||
event: GroupAdminsEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
if (relay != null) {
|
||||
val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent
|
||||
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) }
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
|
||||
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
|
||||
* content kinds and scopes them with `h`, so the note is consumed normally
|
||||
* and then, when it belongs to a group and we know the serving relay, added
|
||||
* to that group's channel timeline.
|
||||
*/
|
||||
private fun attachToRelayGroupIfScoped(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
val groupId = event.groupId() ?: return
|
||||
val note = getOrCreateNote(event.id)
|
||||
// Only attach a note we've actually loaded — never a placeholder for an
|
||||
// unverified/not-yet-seen event. This is checked here (not via the "was
|
||||
// newly consumed" flag) so the host relay's echo of an event we already
|
||||
// stored from our own send still lands in the channel.
|
||||
if (note.event == null) return
|
||||
|
||||
if (relay != null) {
|
||||
// Normal arrival: the group only exists on its host relay and the
|
||||
// filters are host-pinned, so the serving relay is the group's key.
|
||||
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addNote(note, relay)
|
||||
} else {
|
||||
// Our own optimistic send has no provenance relay, so we can't build the (groupId,
|
||||
// relay) key. Attach only when a SINGLE open channel has this group id (the room being
|
||||
// composed in). When the id is ambiguous across relays — e.g. the relay-wide "_" group
|
||||
// joined on several relays — skip: attaching to all of them bleeds the message into
|
||||
// rooms it wasn't sent to. The host relay's echo (relay != null) lands it on the right key.
|
||||
relayGroupChannels
|
||||
.filter { key, _ -> key.id == groupId }
|
||||
.singleOrNull()
|
||||
?.addNote(note, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same routing as [attachToRelayGroupIfScoped] but for kind-11 threads, which
|
||||
* are kept in a separate collection from the chat timeline so the two content
|
||||
* types don't mix in one feed.
|
||||
*/
|
||||
private fun attachThreadToRelayGroupIfScoped(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
val groupId = event.groupId() ?: return
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event == null) return
|
||||
|
||||
if (relay != null) {
|
||||
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addThread(note)
|
||||
} else {
|
||||
// See attachToRelayGroupIfScoped: only attach when the group id is unambiguous.
|
||||
relayGroupChannels
|
||||
.filter { key, _ -> key.id == groupId }
|
||||
.singleOrNull()
|
||||
?.addThread(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LiveActivitiesChatMessageEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2655,6 +2794,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
publicChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
relayGroupChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
}
|
||||
|
||||
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
|
||||
@@ -2705,6 +2848,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
relayGroupChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
chatroomList.forEach { userHex, room ->
|
||||
// History floors are pinned per scope on first advance; null means that window never paged
|
||||
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
|
||||
@@ -3542,6 +3689,75 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups +
|
||||
// servers. Replaceable like its sibling lists; RelayGroupListState reads it from the
|
||||
// addressable cache, so it must be stored (it was silently dropped before).
|
||||
is SimpleGroupListEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupMetadataEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupMembersEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupAdminsEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
// Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003
|
||||
// roles, 39004 AV participants) are durable group state alongside 39000/39001/39002,
|
||||
// so they're stored replaceably. The 9xxx moderation actions and join/leave requests
|
||||
// are regular one-shot events the relay is authoritative for (it applies them and
|
||||
// republishes the 39000/39001/39002); we store them so they're queryable and don't
|
||||
// fall through to the "Not Supported" warning, but we don't act on them client-side.
|
||||
is SupportedRolesEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupParticipantsEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is PutUserEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RemoveUserEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is EditMetadataEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is DeleteEventEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is DeleteGroupEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is CreateGroupEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is CreateInviteEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is JoinRequestEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is LeaveRequestEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is ExternalIdentitiesEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
@@ -3896,11 +4112,25 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
is ChatEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
// Attach on every arrival, not just the newly-consumed one:
|
||||
// our own send is consumed first with a null relay, so the
|
||||
// host relay's later echo (new == false) is what carries the
|
||||
// provenance needed to key the channel. attach is idempotent.
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is PollEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is ThreadEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachThreadToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is PollResponseEvent -> {
|
||||
|
||||
+32
@@ -21,16 +21,48 @@
|
||||
package com.vitorpamplona.amethyst.model.nip11RelayInfo
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun loadRelayInfo(relay: NormalizedRelayUrl): State<Nip11RelayInformation> = loadRelayInfo(relay, Amethyst.instance.nip11Cache)
|
||||
|
||||
/**
|
||||
* Eagerly warms the NIP-11 cache for a whole set of [relays] **in parallel** (each fetch on its own
|
||||
* coroutine), so callers that later read the cache — e.g. the NIP-29 relay-signed group check — get
|
||||
* hits instead of cold fetches. Warming a set serially would sum every relay's latency and let one
|
||||
* slow/unreachable relay stall the rest until its socket timeout; fanning out bounds the wait to the
|
||||
* slowest single fetch. [onEachLoaded] fires (on an IO thread) as each relay resolves, so a screen
|
||||
* can re-evaluate incrementally as docs arrive. The cache dedups, so re-warming is cheap.
|
||||
*/
|
||||
@Composable
|
||||
fun WarmNip11(
|
||||
relays: Collection<NormalizedRelayUrl>,
|
||||
onEachLoaded: () -> Unit = {},
|
||||
) {
|
||||
val cache = Amethyst.instance.nip11Cache
|
||||
LaunchedEffect(relays) {
|
||||
coroutineScope {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
cache.loadRelayInfo(
|
||||
relay = relay,
|
||||
onInfo = { onEachLoaded() },
|
||||
onError = { _, _, _ -> onEachLoaded() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun loadRelayInfo(
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+10
-5
@@ -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 -> {
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip11RelayInfo
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
|
||||
/**
|
||||
* Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g.
|
||||
* "29"). Reads only the in-memory cache — it never blocks on a network fetch — so it returns false
|
||||
* for a relay whose NIP-11 hasn't been loaded yet. Callers that need the answer to become true must
|
||||
* warm the document first (e.g. `loadRelayInfo`), then re-evaluate once it resolves.
|
||||
*/
|
||||
fun relayAdvertisesNip(
|
||||
relay: NormalizedRelayUrl,
|
||||
nip: String,
|
||||
): Boolean =
|
||||
Amethyst.instance.nip11Cache
|
||||
.getFromCache(relay)
|
||||
.supported_nips
|
||||
?.any { it == nip } == true
|
||||
|
||||
/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */
|
||||
fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29")
|
||||
|
||||
/**
|
||||
* Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29:
|
||||
* "these are addressable events signed by the relay keypair directly … as stated by the NIP-11
|
||||
* `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else".
|
||||
*
|
||||
* So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self`
|
||||
* key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29
|
||||
* relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we
|
||||
* fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self`
|
||||
* still works. A relay with neither fails. Reads only the cached NIP-11 doc ([relayInfo]); callers
|
||||
* driving a live surface should warm it first and re-evaluate as it resolves.
|
||||
*/
|
||||
fun isRelaySignedRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
relayInfo: Nip11RelayInformation,
|
||||
): Boolean {
|
||||
val self = relayInfo.self
|
||||
return if (self != null) {
|
||||
channel.event?.pubKey == self
|
||||
} else {
|
||||
relayInfo.supported_nips?.any { it == "29" } == true
|
||||
}
|
||||
}
|
||||
|
||||
/** [isRelaySignedRelayGroup] reading the host relay's cached NIP-11 doc (for non-Compose callers). */
|
||||
fun isRelaySignedRelayGroup(channel: RelayGroupChannel): Boolean = isRelaySignedRelayGroup(channel, Amethyst.instance.nip11Cache.getFromCache(channel.groupId.relayUrl))
|
||||
+20
@@ -37,6 +37,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.P
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelayFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
|
||||
@@ -113,6 +118,16 @@ class RelaySubscriptionsCoordinator(
|
||||
|
||||
// active depending on the screen.
|
||||
val channel = ChannelFilterAssembler(client)
|
||||
|
||||
// NIP-29 relay-group REQ assemblers, each named for WHEN it runs. There is deliberately no
|
||||
// "group chat" assembler: a group's kind-9 chat timeline is served by the shared [channel]
|
||||
// assembler above (same as NIP-28 public chats), so only the group-specific surfaces get their
|
||||
// own here.
|
||||
val relayGroupsOnRelay = RelayGroupsOnRelayFilterAssembler(client) // browsing one relay's channel list
|
||||
val relayGroupMyJoinedGroups = RelayGroupMyJoinedGroupsFilterAssembler(client) // metadata+rosters of groups I've joined
|
||||
val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab
|
||||
val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened
|
||||
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed
|
||||
val chatroom = ChatroomFilterAssembler(client)
|
||||
val community = CommunityFilterAssembler(client)
|
||||
val gitRepository = RepositoryFilterAssembler(client)
|
||||
@@ -174,6 +189,11 @@ class RelaySubscriptionsCoordinator(
|
||||
|
||||
val all =
|
||||
listOf(
|
||||
relayGroupsOnRelay,
|
||||
relayGroupMyJoinedGroups,
|
||||
relayGroupThreadFeed,
|
||||
relayGroupWarmup,
|
||||
relayGroupsDiscovery,
|
||||
account,
|
||||
accountForeground,
|
||||
home,
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
+38
-11
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -49,19 +50,38 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
override fun updateFilter(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
key.account.notificationRelays.flow.value.flatMap {
|
||||
filterSummaryNotificationsToPubkey(
|
||||
relay = it,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
) +
|
||||
filterNotificationsToPubkey(
|
||||
): List<RelayBasedFilter> {
|
||||
val inbox =
|
||||
key.account.notificationRelays.flow.value.flatMap {
|
||||
filterSummaryNotificationsToPubkey(
|
||||
relay = it,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
|
||||
)
|
||||
}
|
||||
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
) +
|
||||
filterNotificationsToPubkey(
|
||||
relay = it,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-29 group activity (reactions/replies to my messages) lives on the group's host relay,
|
||||
// not my inbox relays — poll each host relay for those, scoped by `#h` to my joined groups.
|
||||
val groups =
|
||||
key.account.relayGroupList.liveRelayGroupList.value
|
||||
.groupBy({ it.relayUrl }, { it.groupId })
|
||||
.flatMap { (relayUrl, groupIds) ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@flatMap emptyList()
|
||||
filterGroupNotificationsToPubkey(
|
||||
relay = relay,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
groupIds = groupIds.distinct(),
|
||||
since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
)
|
||||
}
|
||||
|
||||
return inbox + groups
|
||||
}
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@@ -76,6 +96,13 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
// Re-subscribe when I join/leave a group so its host relay is added to (or dropped
|
||||
// from) the notification query.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.relayGroupList.liveRelayGroupList.sample(1000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
|
||||
+47
@@ -57,6 +57,25 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/**
|
||||
* Kinds that notify me about activity on MY messages inside a NIP-29 group — reactions, replies
|
||||
* (chat + NIP-22 comments), poll responses, reposts, zaps and reports. Scoped by `#h` to my joined
|
||||
* groups + `#p` to me. These live on the group's host relay, not my inbox relays.
|
||||
*/
|
||||
val GroupNotificationKinds =
|
||||
listOf(
|
||||
ReactionEvent.KIND,
|
||||
ChatEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
PollResponseEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
)
|
||||
|
||||
val SummaryKinds =
|
||||
listOf(
|
||||
@@ -167,6 +186,34 @@ fun filterNotificationsToPubkey(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifications for activity on my messages inside joined NIP-29 groups, queried on the group's
|
||||
* HOST relay (where the content lives) scoped by `#h` to those groups and `#p` to me. Without this,
|
||||
* a reaction/reply to my group message is only fetched when I open the group, never surfacing in
|
||||
* the notifications feed.
|
||||
*/
|
||||
fun filterGroupNotificationsToPubkey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
groupIds: List<String>,
|
||||
since: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = GroupNotificationKinds,
|
||||
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
|
||||
@@ -48,6 +48,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -204,6 +206,8 @@ fun uriToRoute(
|
||||
return connectedAppRoute(uri)
|
||||
}
|
||||
|
||||
relayGroupInviteRoute(uri)?.let { return it }
|
||||
|
||||
val nip19 = Nip19Parser.uriToRoute(uri)?.entity
|
||||
if (nip19 != null) {
|
||||
LocalCache.consume(nip19)
|
||||
@@ -230,10 +234,11 @@ fun uriToRoute(
|
||||
}
|
||||
|
||||
is NAddress -> {
|
||||
routeFor(
|
||||
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
|
||||
loggedIn = account,
|
||||
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
|
||||
relayGroupDirectRoute(nip19)
|
||||
?: routeFor(
|
||||
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
|
||||
loggedIn = account,
|
||||
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
|
||||
}
|
||||
|
||||
is NEmbed -> {
|
||||
@@ -311,3 +316,26 @@ private fun calendarDirectRoute(nip19: NAddress): Route? =
|
||||
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct route for a NIP-29 group `naddr` (kind 39000) shared as a link. The group
|
||||
* only exists on its host relay, so the naddr's relay hint is mandatory — the group
|
||||
* id alone can't be resolved. With a hint we open the group chat straight away
|
||||
* (cold start included); without one there's nowhere to look, so fall through.
|
||||
*/
|
||||
private fun relayGroupDirectRoute(nip19: NAddress): Route? {
|
||||
if (nip19.kind != GroupMetadataEvent.KIND) return null
|
||||
val relay = nip19.relay.firstOrNull() ?: return null
|
||||
return Route.RelayGroup(nip19.dTag, relay.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct route for a NIP-29 group invite link in the de-facto `<relay>'<groupId>[?code=<code>]`
|
||||
* form shared by Wisp and 0xchat (e.g. `wss://groups.0xchat.com'abc123`). The link carries its
|
||||
* own host relay, so we can open the group straight away. Returns null for anything that isn't
|
||||
* this exact shape, so it's safe to try before the nostr-entity parser.
|
||||
*/
|
||||
private fun relayGroupInviteRoute(uri: String): Route? {
|
||||
val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null
|
||||
return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code)
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Renders a NIP-29 group invite link (`<relay>'<groupId>[?code=<code>]`, the Wisp/0xchat
|
||||
* form) inline as a tappable link that opens the group. If the literal can't be parsed
|
||||
* (it always should, since detection produced it) it falls back to plain text.
|
||||
*/
|
||||
@Composable
|
||||
fun ClickableRelayGroupLink(
|
||||
linkText: String,
|
||||
nav: INav,
|
||||
) {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val invite = remember(linkText) { GroupInviteLink.parse(linkText) }
|
||||
|
||||
if (invite == null) {
|
||||
Text(text = linkText)
|
||||
return
|
||||
}
|
||||
|
||||
val clickableModifier =
|
||||
remember(linkText) {
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboardManager.setText(linkText)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
nav.nav(Route.RelayGroup(invite.groupId, invite.relayUrl.url, inviteCode = invite.code))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = linkText,
|
||||
modifier = clickableModifier,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
@@ -77,6 +77,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNIP19
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
@@ -201,6 +202,21 @@ private fun DisplayAddress(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// A NIP-29 group is addressed by its relay-signed kind-39000 metadata. Route
|
||||
// straight into the group chat rather than the generic addressable-note view.
|
||||
// The relay is only carried in the naddr's relay hint (the group id is unique
|
||||
// only per relay), so we need that hint to open it.
|
||||
val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null
|
||||
if (groupRelay != null) {
|
||||
CreateClickableText(
|
||||
clickablePart = "#${nip19.dTag}",
|
||||
suffix = additionalChars,
|
||||
route = Route.RelayGroup(nip19.dTag, groupRelay.url),
|
||||
nav = nav,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.aTag())) }
|
||||
|
||||
if (noteBase == null) {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
|
||||
|
||||
/**
|
||||
* Renders a NIP-29 group invite link (`<relay>'<groupId>[?code=<code>]`) as a full-width
|
||||
* card. It draws immediately with a stable layout — a robohash avatar seeded from the group
|
||||
* id, the id as a placeholder name, and the host relay — then fills in the real name, picture
|
||||
* and member count reactively as the relay-signed metadata arrives, WITHOUT changing the
|
||||
* card's structure or height. While on screen it also warms the group (live metadata +
|
||||
* recent messages/threads) so tapping it opens an already-populated screen.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupCard(
|
||||
linkText: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val invite = remember(linkText) { GroupInviteLink.parse(linkText) }
|
||||
if (invite == null) {
|
||||
// Detection produced this, so it should always parse; degrade to a plain link if not.
|
||||
ClickableRelayGroupLink(linkText, nav)
|
||||
return
|
||||
}
|
||||
|
||||
val groupId = remember(invite) { invite.toGroupId() }
|
||||
|
||||
LoadRelayGroupChannel(groupId, accountViewModel) { channel ->
|
||||
RelayGroupCardContent(channel, invite.code, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupCardContent(
|
||||
baseChannel: RelayGroupChannel,
|
||||
inviteCode: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
|
||||
|
||||
// Recompose in place when the relay-signed metadata / roster changes.
|
||||
val channelState by baseChannel
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
val memberCount = channel.memberCount()
|
||||
val relayLabel = channel.groupId.relayUrl.displayUrl()
|
||||
|
||||
// Closed (invite-only) is the more actionable signal to a prospective joiner than
|
||||
// private, so it wins when both are set. Null for a plain open group.
|
||||
val statusBadge =
|
||||
when {
|
||||
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
|
||||
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Shown only when the group actually has an about, so description-less groups stay
|
||||
// compact. It appears (a small one-time grow) once the relay-signed metadata loads.
|
||||
val description = channel.summary()?.takeIf { it.isNotBlank() }
|
||||
|
||||
ElevatedCard(
|
||||
onClick = {
|
||||
nav.nav(
|
||||
Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url, inviteCode = inviteCode),
|
||||
)
|
||||
},
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = channel.groupId.id,
|
||||
model = channel.profilePicture(),
|
||||
contentDescription = channel.toBestDisplayName(),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (statusBadge != null) {
|
||||
StatusPill(statusBadge)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (memberCount > 0) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Group,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "·",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = relayLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = stringRes(R.string.relay_group_open),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small tonal pill flagging group visibility/join policy (e.g. Private / Invite-only). */
|
||||
@Composable
|
||||
private fun StatusPill(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RelayGroupLinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
|
||||
@@ -531,6 +532,8 @@ private fun RenderWordWithoutPreview(
|
||||
|
||||
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
|
||||
|
||||
is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav)
|
||||
|
||||
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
|
||||
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
|
||||
@@ -569,6 +572,7 @@ private fun RenderWordWithPreview(
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
|
||||
is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav)
|
||||
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ object ScrollStateKeys {
|
||||
const val PICTURES_SCREEN = "PicturesFeed"
|
||||
const val WORKOUTS_SCREEN = "WorkoutsFeed"
|
||||
const val GIT_REPOSITORIES_SCREEN = "GitRepositoriesFeed"
|
||||
const val RELAY_GROUPS_DISCOVERY_SCREEN = "RelayGroupsDiscoveryFeed"
|
||||
const val CALENDARS_SCREEN = "CalendarsFeed"
|
||||
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
|
||||
const val PRODUCTS_SCREEN = "ProductsFeed"
|
||||
|
||||
@@ -105,6 +105,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemC
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupBrowseScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupCreateScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupEditScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||
@@ -212,6 +220,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
|
||||
@@ -367,6 +376,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||
@@ -463,6 +473,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.ComposeSettings> { ComposeSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
|
||||
@@ -555,6 +566,68 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroup> {
|
||||
RelayGroupChatScreen(
|
||||
id = it.id,
|
||||
relayUrl = it.relayUrl,
|
||||
draftId = it.draftId,
|
||||
replyToId = it.replyTo,
|
||||
inviteCode = it.inviteCode,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupServer> {
|
||||
RelayGroupChannelListScreen(
|
||||
relayUrl = it.relayUrl,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupMembers> {
|
||||
RelayGroupMembersScreen(
|
||||
id = it.id,
|
||||
relayUrl = it.relayUrl,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupThreads> {
|
||||
RelayGroupThreadsScreen(
|
||||
id = it.id,
|
||||
relayUrl = it.relayUrl,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
relayUrl = it.relayUrl,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupEdit> {
|
||||
RelayGroupEditScreen(
|
||||
id = it.id,
|
||||
relayUrl = it.relayUrl,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupBrowse> {
|
||||
RelayGroupBrowseScreen(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
@@ -667,6 +740,8 @@ fun BuildNavigation(
|
||||
forkId = it.fork,
|
||||
versionId = it.version,
|
||||
draftId = it.draft,
|
||||
groupThreadId = it.groupThreadId,
|
||||
groupThreadRelayUrl = it.groupThreadRelayUrl,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
+9
@@ -66,6 +66,7 @@ enum class NavBarItem {
|
||||
PODCAST_EPISODES,
|
||||
PODCASTS,
|
||||
PUBLIC_CHATS,
|
||||
RELAY_GROUPS,
|
||||
FOLLOW_PACKS,
|
||||
LIVE_STREAMS,
|
||||
NESTS,
|
||||
@@ -318,6 +319,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.AutoMirrored.Chat,
|
||||
resolveRoute = { Route.PublicChats },
|
||||
),
|
||||
NavBarItem.RELAY_GROUPS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.RELAY_GROUPS,
|
||||
labelRes = R.string.relay_groups_title,
|
||||
icon = MaterialSymbols.Forum,
|
||||
resolveRoute = { Route.RelayGroups },
|
||||
),
|
||||
NavBarItem.FOLLOW_PACKS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.FOLLOW_PACKS,
|
||||
@@ -439,6 +447,7 @@ val DrawerFeedsItems: List<NavBarItem> =
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -44,6 +45,9 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
@@ -71,8 +75,25 @@ fun routeFor(
|
||||
return Route.MarmotGroupChat(marmotGroup.nostrGroupId)
|
||||
}
|
||||
|
||||
// NIP-29 relay-group content (kind 9 chat, 11 thread, 1111 comment, polls) should open the
|
||||
// group chat screen — like every other chat NIP — not the generic thread view it would fall
|
||||
// through to. A consumed group message is attached to its RelayGroupChannel (which carries the
|
||||
// host relay), so route via that; fall back to the note's `h` tag + provenance relay otherwise.
|
||||
val relayGroup = note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }
|
||||
if (relayGroup != null) {
|
||||
return routeFor(relayGroup)
|
||||
}
|
||||
|
||||
val noteEvent = note.event ?: return Route.EventRedirect(note.idHex)
|
||||
|
||||
if (noteEvent.isGroupScoped()) {
|
||||
val groupId = noteEvent.groupId()
|
||||
val hostRelay = note.relays.firstOrNull()
|
||||
if (groupId != null && hostRelay != null) {
|
||||
return routeFor(GroupId(groupId, hostRelay))
|
||||
}
|
||||
}
|
||||
|
||||
return routeFor(noteEvent, loggedIn)
|
||||
}
|
||||
|
||||
@@ -257,6 +278,10 @@ fun routeFor(note: LiveActivitiesChannel): Route = Route.LiveActivityChannel(not
|
||||
|
||||
fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl.url)
|
||||
|
||||
fun routeFor(note: RelayGroupChannel): Route = Route.RelayGroup(note.groupId.id, note.groupId.relayUrl.url)
|
||||
|
||||
fun routeFor(groupId: GroupId): Route = Route.RelayGroup(groupId.id, groupId.relayUrl.url)
|
||||
|
||||
fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
|
||||
|
||||
fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex)
|
||||
|
||||
@@ -404,6 +404,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object ReactionsSettings : Route()
|
||||
|
||||
@Serializable object MessagesSettings : Route()
|
||||
|
||||
@Serializable object AudioVisualizerSettings : Route()
|
||||
|
||||
@Serializable object BottomBarSettings : Route()
|
||||
@@ -637,6 +639,43 @@ sealed class Route {
|
||||
|
||||
@Serializable object NewEphemeralChat : Route()
|
||||
|
||||
@Serializable data class RelayGroup(
|
||||
val id: String,
|
||||
val relayUrl: String,
|
||||
val draftId: HexKey? = null,
|
||||
val replyTo: HexKey? = null,
|
||||
// NIP-29 invite code from a `wss://relay'id?code=…` link — auto-joins a closed
|
||||
// group when the screen opens. Null for a plain (view-only) group link.
|
||||
val inviteCode: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupServer(
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupMembers(
|
||||
val id: String,
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupThreads(
|
||||
val id: String,
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupCreate(
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupEdit(
|
||||
val id: String,
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable object RelayGroups : Route()
|
||||
|
||||
@Serializable object RelayGroupBrowse : Route()
|
||||
|
||||
@Serializable data class ChannelMetadataEdit(
|
||||
val id: String? = null,
|
||||
) : Route()
|
||||
@@ -800,6 +839,10 @@ sealed class Route {
|
||||
val fork: String? = null,
|
||||
val version: String? = null,
|
||||
val draft: String? = null,
|
||||
// When set, the composer produces a NIP-29 kind-11 group thread scoped to this group id and
|
||||
// published only to its host relay (see ShortNotePostViewModel.setGroupThread).
|
||||
val groupThreadId: String? = null,
|
||||
val groupThreadRelayUrl: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
|
||||
+32
@@ -91,6 +91,9 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.notify
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
@@ -520,8 +523,31 @@ open class CommentPostViewModel :
|
||||
val anonymous = wantsAnonymousPost
|
||||
cancel()
|
||||
|
||||
// A reply within a NIP-29 group is group content: pin it to the group's
|
||||
// host relay (the relay the thread was seen on) instead of the author's
|
||||
// outbox, so it reaches the group and — for a private/closed group — is
|
||||
// never leaked to unrelated relays.
|
||||
val replyGroupId = replyingTo?.event?.takeIf { it.isGroupScoped() }?.groupId()
|
||||
val groupHostRelays =
|
||||
replyGroupId?.let { gid ->
|
||||
// Prefer the group's cached channel host (the pinned host relay), then the relays
|
||||
// the parent was actually seen on. Never fall through to the author's outbox.
|
||||
LocalCache.relayGroupChannels
|
||||
.filter { key, _ -> key.id == gid }
|
||||
.map { it.groupId.relayUrl }
|
||||
.distinct()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: replyingTo?.relays?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
if (anonymous) {
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
|
||||
} else if (replyGroupId != null) {
|
||||
// Group content: route to the resolved host. If it couldn't be resolved, publish to the
|
||||
// parent's relays (possibly empty) rather than broadcasting to the outbox — better to
|
||||
// under-deliver a group reply than to leak group participation to unrelated relays.
|
||||
val relays = groupHostRelays ?: replyingTo?.relays.orEmpty()
|
||||
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { relays }
|
||||
} else {
|
||||
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
|
||||
}
|
||||
@@ -577,6 +603,12 @@ open class CommentPostViewModel :
|
||||
msg = tagger.message,
|
||||
replyingTo = eventHint,
|
||||
) {
|
||||
// A reply inside a NIP-29 group thread must carry the group's `h`
|
||||
// tag, or the host relay won't accept it and no other member (or
|
||||
// other NIP-29 client, e.g. Flotilla) will see it. Inherit it from
|
||||
// the event being replied to; a no-op for non-group comments.
|
||||
replyingToEvent?.groupId()?.let { hTag(it) }
|
||||
|
||||
val notifyPTags = tagger.pTags?.let { pTagList -> pTagList.map { it.toPTag() } } ?: emptyList()
|
||||
|
||||
val extraNotificationAuthors =
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
|
||||
@@ -328,6 +329,41 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
private val _relayGroupsDiscoveryRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
liveInterestFlows,
|
||||
account.relayGroupList.liveRelayGroupServers,
|
||||
) { peopleLists, interests, joinedServers ->
|
||||
checkNotInMainThread()
|
||||
// A relay chip per host relay of every group the user joined (kind-10009), so they can
|
||||
// browse the OTHER groups on those relays without first having to Favorite them. Deduped
|
||||
// against relays that are already chips because they were favorited (kind-10012, already
|
||||
// in `interests`). Only in this catalog — other feeds (git, podcasts, …) don't want them.
|
||||
val alreadyChip = interests.mapNotNullTo(HashSet()) { (it.code as? TopFilter.Relay)?.url }
|
||||
val joinedRelayChips =
|
||||
joinedServers
|
||||
.asSequence()
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.filter { it.url !in alreadyChip }
|
||||
.distinctBy { it.url }
|
||||
.map { FeedDefinition(TopFilter.Relay(it.url), RelayName(it)) }
|
||||
.sortedBy { it.name.name() }
|
||||
.toList()
|
||||
emit(
|
||||
listOf(
|
||||
// Relay-group discovery routes to relays by author, hashtag and geohash, plus
|
||||
// favorited + joined-group relay chips; "Mine" (the joined-groups view) sits last
|
||||
// in the base group to match the ordering every other feed uses.
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
|
||||
peopleLists,
|
||||
interests,
|
||||
joinedRelayChips,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val _podcastRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
@@ -426,6 +462,11 @@ class TopNavFilterState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val relayGroupsDiscoveryRoutes =
|
||||
_relayGroupsDiscoveryRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val podcastRoutes =
|
||||
_podcastRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
+27
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFi
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarCollectionsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.RelayGroupDiscoveryFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter
|
||||
@@ -113,6 +114,7 @@ class AccountFeedContentStates(
|
||||
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
||||
val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache)
|
||||
val gitRepositoriesFeed = FeedContentState(GitRepositoriesFeedFilter(account), scope, LocalCache)
|
||||
val relayGroupsDiscoveryFeed = FeedContentState(RelayGroupDiscoveryFeedFilter(account), scope, LocalCache)
|
||||
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
|
||||
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
|
||||
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
|
||||
@@ -166,6 +168,28 @@ class AccountFeedContentStates(
|
||||
}
|
||||
}
|
||||
|
||||
// Same for the NIP-29 joined-group list (kind 10009): joining/leaving changes the list but
|
||||
// doesn't flow through newEventBundles, so force a rebuild — otherwise a just-joined group
|
||||
// (whose messages haven't loaded yet) wouldn't appear on the Messages tab until a later event.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.relayGroupList.liveRelayGroupList
|
||||
.drop(1)
|
||||
.collect {
|
||||
dmKnown.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Flipping the NIP-29 view mode (inline groups vs one row per relay) changes what the
|
||||
// Messages feed emits for joined groups, but no event flows through LocalCache — force a
|
||||
// full rebuild so the list switches shape immediately.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.settings.relayGroupViewMode
|
||||
.drop(1)
|
||||
.collect {
|
||||
dmKnown.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Pinning/unpinning a room only changes sort order, not membership, so no
|
||||
// chat event flows through LocalCache. Force a rebuild to re-sort. This
|
||||
// also fires when pins arrive via the synced AppSpecificData event.
|
||||
@@ -232,6 +256,7 @@ class AccountFeedContentStates(
|
||||
picturesFeed.updateFeedWith(newNotes)
|
||||
workoutsFeed.updateFeedWith(newNotes)
|
||||
gitRepositoriesFeed.updateFeedWith(newNotes)
|
||||
relayGroupsDiscoveryFeed.updateFeedWith(newNotes)
|
||||
productsFeed.updateFeedWith(newNotes)
|
||||
shortsFeed.updateFeedWith(newNotes)
|
||||
publicChatsFeed.updateFeedWith(newNotes)
|
||||
@@ -294,6 +319,7 @@ class AccountFeedContentStates(
|
||||
picturesFeed.deleteFromFeed(newNotes)
|
||||
workoutsFeed.deleteFromFeed(newNotes)
|
||||
gitRepositoriesFeed.deleteFromFeed(newNotes)
|
||||
relayGroupsDiscoveryFeed.deleteFromFeed(newNotes)
|
||||
productsFeed.deleteFromFeed(newNotes)
|
||||
shortsFeed.deleteFromFeed(newNotes)
|
||||
publicChatsFeed.deleteFromFeed(newNotes)
|
||||
@@ -352,6 +378,7 @@ class AccountFeedContentStates(
|
||||
picturesFeed.trimToSize(maxItems)
|
||||
workoutsFeed.trimToSize(maxItems)
|
||||
gitRepositoriesFeed.trimToSize(maxItems)
|
||||
relayGroupsDiscoveryFeed.trimToSize(maxItems)
|
||||
calendarAppointmentsFeed.trimToSize(maxItems)
|
||||
calendarCollectionsFeed.trimToSize(maxItems)
|
||||
productsFeed.trimToSize(maxItems)
|
||||
|
||||
+91
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
@@ -143,6 +144,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
@@ -1436,6 +1438,89 @@ class AccountViewModel(
|
||||
|
||||
fun unfollow(channel: EphemeralChatChannel) = launchSigner { account.unfollow(channel) }
|
||||
|
||||
fun joinRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
code: String? = null,
|
||||
) = launchSigner { account.joinRelayGroup(channel, code) }
|
||||
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
|
||||
|
||||
fun createRelayGroup(
|
||||
relay: NormalizedRelayUrl,
|
||||
groupId: String,
|
||||
name: String,
|
||||
about: String?,
|
||||
picture: String?,
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
hashtags: List<String>,
|
||||
geohashes: List<String>,
|
||||
) = launchSigner {
|
||||
account.createRelayGroup(
|
||||
relay,
|
||||
groupId,
|
||||
name,
|
||||
about,
|
||||
picture,
|
||||
isPrivate,
|
||||
isClosed,
|
||||
isHidden,
|
||||
isRestricted,
|
||||
hashtags,
|
||||
geohashes,
|
||||
)
|
||||
}
|
||||
|
||||
fun createRelayGroupInvite(
|
||||
channel: RelayGroupChannel,
|
||||
code: String,
|
||||
) = launchSigner { account.createRelayGroupInvite(channel, code) }
|
||||
|
||||
fun postRelayGroupThread(
|
||||
channel: RelayGroupChannel,
|
||||
title: String,
|
||||
body: String,
|
||||
) = launchSigner { account.postRelayGroupThread(channel, title, body) }
|
||||
|
||||
fun removeRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
) = launchSigner { account.removeRelayGroupUser(channel, pubkey) }
|
||||
|
||||
fun putRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
roles: List<String>,
|
||||
) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) }
|
||||
|
||||
fun editRelayGroupMetadata(
|
||||
channel: RelayGroupChannel,
|
||||
name: String?,
|
||||
about: String?,
|
||||
picture: String?,
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
hashtags: List<String>,
|
||||
geohashes: List<String>,
|
||||
) = launchSigner {
|
||||
account.editRelayGroupMetadata(
|
||||
channel,
|
||||
name,
|
||||
about,
|
||||
picture,
|
||||
isPrivate,
|
||||
isClosed,
|
||||
isHidden,
|
||||
isRestricted,
|
||||
hashtags,
|
||||
geohashes,
|
||||
)
|
||||
}
|
||||
|
||||
fun follow(users: List<User>) = launchSigner { account.follow(users) }
|
||||
|
||||
fun follow(user: User) = launchSigner { account.follow(user) }
|
||||
@@ -1711,6 +1796,12 @@ class AccountViewModel(
|
||||
|
||||
fun getEphemeralChatChannelIfExists(key: RoomId) = LocalCache.getEphemeralChatChannelIfExists(key)
|
||||
|
||||
fun checkGetOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = LocalCache.getOrCreateRelayGroupChannel(key)
|
||||
|
||||
fun getRelayGroupChannelIfExists(key: GroupId) = LocalCache.getRelayGroupChannelIfExists(key)
|
||||
|
||||
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl) = LocalCache.getRelayGroupChannelsOnRelay(relay)
|
||||
|
||||
fun getLiveActivityChannelIfExists(key: Address) = LocalCache.getLiveActivityChannelIfExists(key)
|
||||
|
||||
fun <T : PubKeyReferenceTag> loadParticipants(
|
||||
|
||||
+3
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
|
||||
@@ -131,6 +132,8 @@ private fun PreloadFor(
|
||||
|
||||
NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
NavBarItem.RELAY_GROUPS -> RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
|
||||
|
||||
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/** Timeline kinds shown in a NIP-29 group: chat messages and polls. */
|
||||
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
/**
|
||||
* The message timeline for a NIP-29 group. A group's chat lives entirely on its
|
||||
* host relay, scoped by the `h` tag, so the filter is pinned to
|
||||
* [RelayGroupChannel.relays] (always the single host) and never fans out to the
|
||||
* user's other relays.
|
||||
*/
|
||||
fun filterMessagesToRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
channel.relays().toSet().map {
|
||||
RelayBasedFilter(
|
||||
relay = it,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
|
||||
limit = 200,
|
||||
since = since?.get(it)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
|
||||
/** Relay-signed group directory kinds: metadata + admins + members + roles. */
|
||||
private val RELAY_GROUP_METADATA_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
SupportedRolesEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* The relay-signed metadata for a NIP-29 group (name/picture/about + admin,
|
||||
* member and role lists), addressed by the group id (`d` tag) and pinned to the
|
||||
* group's host relay. The relay signs these with its own key, so a single-relay
|
||||
* query scoped by `#d` returns exactly this group's directory.
|
||||
*/
|
||||
fun filterMetadataToRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
channel.relays().toSet().map {
|
||||
RelayBasedFilter(
|
||||
relay = it,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_METADATA_KINDS,
|
||||
tags = mapOf("d" to listOf(channel.groupId.id)),
|
||||
since = since?.get(it)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
/**
|
||||
* The current user's own messages in a NIP-29 group. A smaller companion to
|
||||
* [filterMessagesToRelayGroup] that back-fills the user's sent messages (so
|
||||
* optimistic sends reconcile) — pinned to the group's host relay and scoped by
|
||||
* both the `h` group tag and the author.
|
||||
*/
|
||||
fun filterMyMessagesToRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
pubKey: HexKey,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
channel.relays().toSet().map {
|
||||
RelayBasedFilter(
|
||||
relay = it,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
|
||||
authors = listOf(pubKey),
|
||||
limit = 50,
|
||||
since = since?.get(it)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Mint a NIP-29 invite (kind 9009) for the group as soon as the dialog opens and
|
||||
* show the code to copy and share. Requires admin/moderator rights on the relay.
|
||||
*/
|
||||
@Composable
|
||||
fun InviteRelayGroupDialog(
|
||||
channel: RelayGroupChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val code by remember { mutableStateOf(RandomInstance.bytes(6).toHexKey()) }
|
||||
|
||||
// Observe the relay-signed metadata so the naddr / closed-state fill in live: the
|
||||
// dialog is often opened before the kind-39000 has arrived, and reading a snapshot
|
||||
// there would leave the Copy button permanently disabled once it does load.
|
||||
val channelState by channel
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
|
||||
|
||||
// A shareable, cross-client coordinate for the group (opens the chat in any
|
||||
// NIP-29 client). Null until the relay-signed metadata has loaded.
|
||||
val nAddr = liveChannel.toNAddr()?.let { "nostr:$it" }
|
||||
val isClosed = liveChannel.isClosed()
|
||||
|
||||
// A join code is only meaningful for closed (invite-only) groups; open groups
|
||||
// join directly from the shared naddr. So mint the kind-9009 invite only when
|
||||
// the group is actually closed, rather than on every dialog open.
|
||||
LaunchedEffect(code, isClosed) {
|
||||
if (isClosed) accountViewModel.createRelayGroupInvite(channel, code)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(R.string.relay_group_invite_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(stringRes(R.string.relay_group_invite_description))
|
||||
|
||||
if (nAddr != null) {
|
||||
Text(
|
||||
text = nAddr,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 2,
|
||||
)
|
||||
} else {
|
||||
// Metadata (the group's pubkey, needed for the naddr) hasn't loaded yet.
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_invite_preparing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Closed groups additionally need a one-time code to join.
|
||||
if (isClosed) {
|
||||
Text(stringRes(R.string.relay_group_invite_code_label))
|
||||
Text(
|
||||
text = code,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
// Copy the group link, plus the code when the group is closed (so a
|
||||
// recipient has both to join). Never fall back to copying a code the
|
||||
// dialog didn't show — for an open group with metadata not yet loaded
|
||||
// there is simply nothing to copy, so disable the button.
|
||||
val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n")
|
||||
TextButton(
|
||||
enabled = toCopy.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch { clipboard.setText(toCopy) }
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.copy))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+41
@@ -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) }
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults.cardElevation
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelaySuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.ShowRelaySuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* A handful of widely-used public NIP-29 relays to seed discovery for a user who
|
||||
* hasn't joined any group yet. Not exhaustive and not an endorsement — just a
|
||||
* starting point; anyone can paste any relay URL above.
|
||||
*/
|
||||
private val POPULAR_RELAYS =
|
||||
listOf(
|
||||
"wss://groups.0xchat.com",
|
||||
"wss://communities.nos.social",
|
||||
)
|
||||
|
||||
/**
|
||||
* Discovery entry point for NIP-29 groups: paste any relay URL to browse the
|
||||
* channels it hosts (opening [Route.RelayGroupServer]), pick from relays you're
|
||||
* already on, or try a popular public relay. This is the "find new groups" flow
|
||||
* that the joined-only Messages list can't offer.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupBrowseScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var relayUrl by remember { mutableStateOf("") }
|
||||
var showError by remember { mutableStateOf(false) }
|
||||
|
||||
// Same known-relay autocomplete every other relay field uses: filters the relays the app has
|
||||
// already seen (LocalCache.relayHints) by the substring being typed.
|
||||
val relaySuggestions = remember { RelaySuggestionState() }
|
||||
val suggestionResults by relaySuggestions.results.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupServers
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
fun open(url: String) {
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())
|
||||
if (normalized == null) {
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
relaySuggestions.reset()
|
||||
nav.nav(Route.RelayGroupServer(normalized.url))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_browse_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_browse_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = relayUrl,
|
||||
onValueChange = {
|
||||
relayUrl = it
|
||||
showError = false
|
||||
relaySuggestions.processInput(it)
|
||||
},
|
||||
singleLine = true,
|
||||
isError = showError,
|
||||
supportingText =
|
||||
if (showError) {
|
||||
{ Text(stringRes(R.string.relay_group_browse_invalid_url)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
label = { Text(stringRes(R.string.relay_group_browse_relay_label)) },
|
||||
placeholder = { Text("wss://…") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { open(relayUrl) }),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
// Autocomplete popup: tapping a match opens that relay's group directory directly.
|
||||
if (suggestionResults.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
elevation = cardElevation(5.dp),
|
||||
shape = PopupUpEffect,
|
||||
) {
|
||||
ShowRelaySuggestionList(
|
||||
relaySuggestions = relaySuggestions,
|
||||
onSelect = { relay -> open(relay.url) },
|
||||
modifier = HalfHorzPadding,
|
||||
nip11CachedRetriever = Amethyst.instance.nip11Cache,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { open(relayUrl) },
|
||||
enabled = relayUrl.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(stringRes(R.string.relay_group_browse_go))
|
||||
}
|
||||
|
||||
if (joined.isNotEmpty()) {
|
||||
SectionHeader(stringRes(R.string.relay_group_browse_your_relays))
|
||||
joined.sorted().forEach { server ->
|
||||
RelayGroupServerRow(server, accountViewModel) { open(server) }
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
|
||||
// `joined` holds normalized URLs (trailing-slash form); normalize the
|
||||
// literals before comparing so an already-joined popular relay is hidden.
|
||||
val joinedNormalized = joined.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it)?.url }
|
||||
val suggestions = POPULAR_RELAYS.filter { RelayUrlNormalizer.normalizeOrNull(it)?.url !in joinedNormalized }
|
||||
if (suggestions.isNotEmpty()) {
|
||||
SectionHeader(stringRes(R.string.relay_group_browse_popular))
|
||||
suggestions.forEach { server ->
|
||||
RelayGroupServerRow(server, accountViewModel) { open(server) }
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
/** A first screen's worth of recent messages to prefetch per visible group card, ahead of a tap. */
|
||||
private const val CHANNEL_LIST_WARMUP_LIMIT = 10
|
||||
|
||||
/**
|
||||
* Lists every channel a relay hosts (its kind 39000-39003 directory), so the user
|
||||
* can browse and open channels on that relay. The relay's directory is streamed by
|
||||
* [RelayGroupsOnRelaySubscription] and consumed into per-group channels; this
|
||||
* screen reads them back for the relay and renders them. Each visible card also warms
|
||||
* its group's recent messages so opening a chat lands on cached content.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupChannelListScreen(
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
|
||||
|
||||
RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel)
|
||||
|
||||
// Warm the relay's NIP-11 so we can tell its genuine (relay-signed) groups from stray
|
||||
// user-published 39000s that a non-NIP-29 relay may also be storing.
|
||||
val relayInfo by loadRelayInfo(relay)
|
||||
|
||||
// Re-read the relay's channels whenever a group-metadata (kind 39000) event lands in
|
||||
// the cache — driven by LocalCache.observeEvents rather than a timer, so the list
|
||||
// updates as directory events arrive with no polling. The initial value is sorted too
|
||||
// so the first frame doesn't reshuffle when the first emission arrives.
|
||||
val allChannels by produceState(
|
||||
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() },
|
||||
relay,
|
||||
) {
|
||||
LocalCache
|
||||
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
|
||||
.collect {
|
||||
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }
|
||||
}
|
||||
}
|
||||
|
||||
// Only the relay's own genuine, relay-signed groups (39000 author == the relay's NIP-11 `self`).
|
||||
// Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden.
|
||||
val channels = remember(allChannels, relayInfo) { allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
Text(
|
||||
text = relay.displayUrl(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(R.string.relay_group_create_title),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
if (channels.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_channels_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(32.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(padding)) {
|
||||
itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupChannelRow(
|
||||
channel: RelayGroupChannel,
|
||||
myPubkey: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
val joined = channel.membershipOf(myPubkey).isMember()
|
||||
val memberCount = channel.memberCount()
|
||||
|
||||
// Anticipate a tap: while this row is on-screen, prefetch a first screen's worth of recent
|
||||
// messages for its group (content only — the directory subscription already streams metadata),
|
||||
// so opening the chat lands on cached content instead of a blank load. Bounded to visible rows
|
||||
// by the LazyColumn, and released as they scroll off.
|
||||
RelayGroupWarmupSubscription(
|
||||
channel,
|
||||
accountViewModel.dataSources().relayGroupWarmup,
|
||||
accountViewModel,
|
||||
contentOnly = true,
|
||||
contentLimit = CHANNEL_LIST_WARMUP_LIMIT,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = channel.groupId.id,
|
||||
model = channel.profilePicture(),
|
||||
contentDescription = channel.toBestDisplayName(),
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (channel.isPrivate()) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val subtitle =
|
||||
channel.summary()?.takeIf { it.isNotBlank() }
|
||||
?: if (memberCount > 0) {
|
||||
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (joined) {
|
||||
Box(Modifier.size(20.dp).clip(CircleShape)) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Check,
|
||||
contentDescription = stringRes(R.string.relay_group_role_member),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
@Composable
|
||||
fun RelayGroupChannelView(
|
||||
channelId: GroupId?,
|
||||
draft: Note? = null,
|
||||
replyTo: Note? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (channelId == null) return
|
||||
|
||||
LoadRelayGroupChannel(channelId, accountViewModel) { group ->
|
||||
PrepareChannelViewModels(
|
||||
baseChannel = group,
|
||||
draft = draft,
|
||||
replyTo = replyTo,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrepareChannelViewModels(
|
||||
baseChannel: RelayGroupChannel,
|
||||
draft: Note? = null,
|
||||
replyTo: Note? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val feedViewModel: ChannelFeedViewModel =
|
||||
viewModel(
|
||||
key = baseChannel.groupId.toKey() + "ChannelFeedViewModel",
|
||||
factory =
|
||||
ChannelFeedViewModel.Factory(
|
||||
baseChannel,
|
||||
accountViewModel.account,
|
||||
),
|
||||
)
|
||||
|
||||
val channelScreenModel: ChannelNewMessageViewModel = viewModel()
|
||||
channelScreenModel.init(accountViewModel)
|
||||
channelScreenModel.load(baseChannel)
|
||||
|
||||
if (draft != null) {
|
||||
LaunchedEffect(draft, channelScreenModel, accountViewModel) {
|
||||
channelScreenModel.editFromDraft(draft)
|
||||
}
|
||||
}
|
||||
|
||||
if (replyTo != null) {
|
||||
LaunchedEffect(replyTo, channelScreenModel, accountViewModel) {
|
||||
channelScreenModel.reply(replyTo)
|
||||
}
|
||||
}
|
||||
|
||||
ChannelView(
|
||||
channel = baseChannel,
|
||||
feedViewModel = feedViewModel,
|
||||
newPostModel = channelScreenModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelView(
|
||||
channel: RelayGroupChannel,
|
||||
feedViewModel: ChannelFeedViewModel,
|
||||
newPostModel: ChannelNewMessageViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(feedViewModel)
|
||||
ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier =
|
||||
remember {
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 0.dp)
|
||||
.weight(1f, true)
|
||||
},
|
||||
) {
|
||||
RefreshingChatroomFeedView(
|
||||
feedContentState = feedViewModel.feedState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
routeForLastRead = "RelayGroup/${channel.groupId.toKey()}",
|
||||
avoidDraft = newPostModel.draftTag,
|
||||
onWantsToReply = newPostModel::reply,
|
||||
onWantsToEditDraft = newPostModel::editFromDraft,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
// NIP-29 relays reject writes from non-members, so only show the composer when the
|
||||
// relay-signed roster (39001/39002) lists me as a member/mod/admin. Collect the metadata
|
||||
// flow so the composer appears the moment my join is accepted. Otherwise, explain why.
|
||||
val channelState by channel
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
|
||||
val canPost = liveChannel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember()
|
||||
|
||||
if (canPost) {
|
||||
EditFieldRow(
|
||||
newPostModel,
|
||||
accountViewModel,
|
||||
onSendNewMessage = feedViewModel.feedState::sendToTop,
|
||||
nav,
|
||||
)
|
||||
} else {
|
||||
JoinToPostNotice(liveChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown in place of the composer when I'm not (yet) a member: a relay group won't accept my kind-9
|
||||
* chat until its roster lists me, so typing would only earn a silent relay rejection. Points me at
|
||||
* the top-bar Join action (open groups) or explains the invite requirement (closed groups).
|
||||
*/
|
||||
@Composable
|
||||
private fun JoinToPostNotice(channel: RelayGroupChannel) {
|
||||
val message =
|
||||
if (channel.isClosed()) {
|
||||
stringRes(R.string.relay_group_invite_only_to_post)
|
||||
} else {
|
||||
stringRes(R.string.relay_group_join_to_post)
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
@Composable
|
||||
fun RelayGroupChatScreen(
|
||||
id: HexKey,
|
||||
relayUrl: String,
|
||||
draftId: HexKey? = null,
|
||||
replyToId: HexKey? = null,
|
||||
inviteCode: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
|
||||
val channelId = remember(id, relay) { GroupId(id, relay) }
|
||||
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
|
||||
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = true,
|
||||
topBar = {
|
||||
LoadRelayGroupChannel(channelId, accountViewModel) {
|
||||
RelayGroupTopBar(it, inviteCode, accountViewModel, nav)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
allowBarHide = false,
|
||||
) {
|
||||
Column(Modifier.padding(it)) {
|
||||
RelayGroupChannelView(channelId, draft, replyTo, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
/**
|
||||
* Discover NIP-29 groups across the relay set the top-bar filter resolves to. Built on the shared
|
||||
* feed stack exactly like the Git-repositories screen: a [FeedContentState]
|
||||
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates.relayGroupsDiscoveryFeed])
|
||||
* fed by the per-type datasource, a [FeedFilterSpinner] persisting the selection to
|
||||
* `defaultRelayGroupsDiscoveryFollowList`, and a [RefresheableBox] + [RenderFeedContentState]. Rows
|
||||
* are the relay-signed 39000 metadata notes, rendered as joinable group cards.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupDiscoveryScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RelayGroupDiscoveryScreen(
|
||||
feedContentState = accountViewModel.feedStates.relayGroupsDiscoveryFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayGroupDiscoveryScreen(
|
||||
feedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(feedContentState)
|
||||
WatchAccountForRelayGroupDiscovery(feedContentState, accountViewModel)
|
||||
RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel)
|
||||
// Keep the joined groups' metadata + rosters live so the "My Groups" filter can list them
|
||||
// (their host relays aren't fetched by the discovery filter set).
|
||||
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = { RelayGroupsDiscoveryTopBar(accountViewModel, nav) },
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route ->
|
||||
if (route == Route.RelayGroups) feedContentState.sendToTop() else nav.navBottomBar(route)
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
FloatingActionButton(
|
||||
onClick = { nav.nav(Route.RelayGroupBrowse) },
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(R.string.relay_group_browse_title),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableBox(feedContentState, true) {
|
||||
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.RELAY_GROUPS_DISCOVERY_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = null,
|
||||
onLoaded = { loaded ->
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
// Collect the kind-3 follow set once for the whole list; each card highlights
|
||||
// the members it contains ("people you follow who are in here").
|
||||
val follows by accountViewModel.account.kind3FollowList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
RelayGroupDiscoveryRow(item, Modifier.animateItem(), follows.authors, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Root top bar for the Relay Groups tab — the shared drawer/search bar every top-level feed uses,
|
||||
* with the feed-filter spinner as its title (mirrors GitRepositoriesTopBar). The browse-a-relay
|
||||
* action lives on the FAB, not here, so the bar stays consistent with the other screens.
|
||||
*/
|
||||
@Composable
|
||||
private fun RelayGroupsDiscoveryTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
FeedFilterSpinner(
|
||||
placeholderCode = selectedFilter,
|
||||
explainer = stringRes(R.string.select_list_to_filter),
|
||||
options = options,
|
||||
onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchAccountForRelayGroupDiscovery(
|
||||
feedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val listName by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
val perRelay by accountViewModel.account.liveRelayGroupsDiscoveryFollowListsPerRelay
|
||||
.collectAsStateWithLifecycle()
|
||||
// The "My Groups" filter reads the kind-10009 joined list, so re-scan when it changes
|
||||
// (join/leave) — otherwise a newly-joined group wouldn't appear until another refresh.
|
||||
val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||
.collectAsStateWithLifecycle()
|
||||
val joinedServers by accountViewModel.account.relayGroupList.liveRelayGroupServers
|
||||
.collectAsStateWithLifecycle()
|
||||
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Discovery only shows groups whose 39000 is signed by the host relay's own key (NIP-29's
|
||||
// authority — the NIP-11 `self` pubkey; general relays instead carry stray user-published 39000s
|
||||
// that can't be joined). The feed reads that from each relay's cached NIP-11, so warm every
|
||||
// candidate relay (in parallel — WarmNip11) and re-invalidate as each doc lands, otherwise a
|
||||
// relay whose NIP-11 resolves after its 39000s would stay hidden until a manual refresh.
|
||||
//
|
||||
// The follow-list filter sets resolve relays via the outbox model, but a group's 39000 lives on
|
||||
// its HOST relay. In All-Follows the subassembler probes those host relays (joined kind-10009 +
|
||||
// favorited kind-10012) for follow rosters, so warm them here too — otherwise their groups get
|
||||
// fetched but the self-key gate reads an unwarmed (empty, self=null) NIP-11 and hides them until
|
||||
// the user bounces through Global (whose relay set happens to include them).
|
||||
val candidateRelays =
|
||||
remember(perRelay, joinedServers, favoriteRelays) {
|
||||
buildSet {
|
||||
addAll(perRelay.toGroupConstraints().keys)
|
||||
joinedServers.forEach { server -> RelayUrlNormalizer.normalizeOrNull(server)?.let(::add) }
|
||||
addAll(favoriteRelays)
|
||||
}
|
||||
}
|
||||
var nip11Version by remember { mutableIntStateOf(0) }
|
||||
WarmNip11(candidateRelays) { nip11Version++ }
|
||||
|
||||
LaunchedEffect(listName, perRelay, joinedGroups) {
|
||||
feedContentState.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
|
||||
// A NIP-11 doc resolving doesn't change the feed key (the self-key test lives in the filter's
|
||||
// match, not the key), so force a re-filter directly as each one lands.
|
||||
LaunchedEffect(nip11Version) {
|
||||
feedContentState.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the 39000 metadata note to its live [RelayGroupChannel] (keyed by host relay + group
|
||||
* id) and recomposes in place as the relay-signed metadata / roster changes, so member counts and
|
||||
* membership stay current without moving the row. While the row is on screen it also warms the
|
||||
* group — the newest handful of chat messages / threads — so tapping it opens an already-populated
|
||||
* screen. The warm-up is lifecycle-aware and tears down as the row scrolls off.
|
||||
*/
|
||||
@Composable
|
||||
private fun RelayGroupDiscoveryRow(
|
||||
note: Note,
|
||||
modifier: Modifier,
|
||||
follows: Set<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Resolve the note to its channel the SAME way the feed filter matched/sorted it, so the row
|
||||
// never binds a different relay's (empty) channel than the one the feed qualified.
|
||||
val baseChannel = remember(note) { relayGroupDiscoveryChannelFor(note) } ?: return
|
||||
|
||||
// Prefetch the group's recent content so opening the card lands on a populated screen. The
|
||||
// metadata/rosters are already streaming from the directory subscription, so only ask for
|
||||
// content here (contentOnly) instead of re-requesting 39000-39003 per visible row.
|
||||
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel, contentOnly = true)
|
||||
|
||||
val channelState by baseChannel
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
// Reactive loaded-message count: the preview subscription streams kind-9 chats into the
|
||||
// channel's note cache, and each arrival re-emits this flow, so the "50+ messages" activity
|
||||
// signal grows live while the row is on screen.
|
||||
val notesState by baseChannel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val messageCount = notesState.channel.notes.size()
|
||||
|
||||
// People I follow who are in this group (relay-signed roster ∩ my kind-3 follows). Recomputed
|
||||
// when the roster changes (channelState) or my follow list does.
|
||||
val participatingFollows = remember(channelState, follows) { channel.participatingFollows(follows) }
|
||||
|
||||
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
RelayGroupDiscoveryCard(
|
||||
channel = channel,
|
||||
modifier = modifier,
|
||||
myPubkey = accountViewModel.userProfile().pubkeyHex,
|
||||
isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays,
|
||||
messageCount = messageCount,
|
||||
participatingFollows = participatingFollows,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupDiscoveryCard(
|
||||
channel: RelayGroupChannel,
|
||||
modifier: Modifier,
|
||||
myPubkey: String,
|
||||
isFavoriteRelay: Boolean,
|
||||
messageCount: Int,
|
||||
participatingFollows: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
val isMember = channel.membershipOf(myPubkey).isMember()
|
||||
val memberCount = channel.memberCount()
|
||||
val relay = channel.groupId.relayUrl
|
||||
|
||||
ElevatedCard(
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = channel.groupId.id,
|
||||
model = channel.profilePicture(),
|
||||
contentDescription = channel.toBestDisplayName(),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
DiscoveryStatusPill(channel)
|
||||
}
|
||||
if (memberCount > 0 || messageCount > 0) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (memberCount > 0) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Group,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (memberCount > 0 && messageCount > 0) {
|
||||
Text(
|
||||
text = "·",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (messageCount > 0) {
|
||||
// Loaded from the preview page; a full page reads as "50+", fewer as
|
||||
// the exact count. DISCOVERY_MESSAGE_CAP tracks the preview limit.
|
||||
Text(
|
||||
text =
|
||||
if (messageCount >= DISCOVERY_MESSAGE_CAP) {
|
||||
pluralStringResource(R.plurals.relay_group_message_count_capped, DISCOVERY_MESSAGE_CAP, DISCOVERY_MESSAGE_CAP)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.relay_group_message_count, messageCount, messageCount)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMember) {
|
||||
FilledTonalButton(onClick = {
|
||||
// Open groups join directly; closed ones open the group where the invite
|
||||
// code can be entered.
|
||||
if (channel.isClosed()) {
|
||||
nav.nav(routeFor(channel))
|
||||
} else {
|
||||
accountViewModel.joinRelayGroup(channel)
|
||||
}
|
||||
}) {
|
||||
Text(stringRes(R.string.join))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prominent social proof: the people I follow who are already in this group.
|
||||
if (participatingFollows.isNotEmpty()) {
|
||||
FollowsInGroupRow(
|
||||
follows = participatingFollows,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// The host relay as its own chip. Tapping the chip opens that relay's full group list;
|
||||
// the star INSIDE it is a separate tap target that only favorites the relay (surfacing
|
||||
// its groups under the relay filter). Joining the group is the separate button above.
|
||||
RelayChip(
|
||||
relay = relay,
|
||||
isFavorite = isFavoriteRelay,
|
||||
onToggleFavorite = {
|
||||
if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay)
|
||||
},
|
||||
onOpenRelay = { nav.nav(Route.RelayGroupServer(relay.url)) },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
channel.summary()?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The group's host relay as a tappable chip. The chip body opens that relay's full group list
|
||||
* ([Route.RelayGroupServer]); the star inside it is an independent tap target that only toggles the
|
||||
* relay in the kind-10012 relay-feeds list (a favorited relay, and every group it hosts, then
|
||||
* surfaces under the relay chip in the top-nav filter). Filled/primary when favorited, tonal outline
|
||||
* otherwise — visually distinct from the group's Join button so the scopes don't read as one action.
|
||||
*/
|
||||
@Composable
|
||||
private fun RelayChip(
|
||||
relay: NormalizedRelayUrl,
|
||||
isFavorite: Boolean,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onOpenRelay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
onClick = onOpenRelay,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color =
|
||||
if (isFavorite) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.padding(start = 2.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder,
|
||||
contentDescription = stringRes(R.string.relay_group_favorite_relay),
|
||||
tint =
|
||||
if (isFavorite) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onToggleFavorite)
|
||||
.padding(4.dp)
|
||||
.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = relay.displayUrl(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color =
|
||||
if (isFavorite) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlapping avatars of the people I follow who are in the group, with a "%d people you follow"
|
||||
* caption — social proof that a group is worth joining. Shows up to five faces; the caption always
|
||||
* carries the true total.
|
||||
*/
|
||||
@Composable
|
||||
private fun FollowsInGroupRow(
|
||||
follows: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy((-9).dp)) {
|
||||
follows.take(5).forEach { pubkey ->
|
||||
UserPicture(
|
||||
userHex = pubkey,
|
||||
size = Size25dp,
|
||||
pictureModifier = Modifier.border(1.5.dp, MaterialTheme.colorScheme.surface, CircleShape),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.relay_group_follows_participating, follows.size, follows.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display cap for the loaded-message counter — kept in step with the discovery preview's fetch
|
||||
* limit (RELAY_GROUP_WARMUP_LIMIT), so a chat that returns the full page reads as "50+ messages".
|
||||
*/
|
||||
private const val DISCOVERY_MESSAGE_CAP = 50
|
||||
|
||||
/** Small tonal pill: Invite-only wins over Private, matching the inline group card. */
|
||||
@Composable
|
||||
private fun DiscoveryStatusPill(channel: RelayGroupChannel) {
|
||||
val label =
|
||||
when {
|
||||
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
|
||||
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
|
||||
else -> return
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* The roster of a NIP-29 group: everyone the relay lists as an admin (kind 39001)
|
||||
* or member (kind 39002), each with their role badge. A moderator (admin or
|
||||
* moderator role) additionally gets a per-user overflow menu to promote/demote
|
||||
* (kind 9000 put-user) and kick (kind 9001 remove-user); the host relay enforces
|
||||
* the actual permission, this UI just surfaces the actions.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupMembersScreen(
|
||||
id: HexKey,
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
|
||||
val channelId = remember(id, relay) { GroupId(id, relay) }
|
||||
|
||||
LoadRelayGroupChannel(channelId, accountViewModel) { channel ->
|
||||
RelayGroupMembers(channel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
private class RosterEntry(
|
||||
val pubkey: HexKey,
|
||||
val membership: RelayGroupMembership,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupMembers(
|
||||
baseChannel: RelayGroupChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Fetch the relay-signed roster (kinds 39001/39002) from the host relay while this
|
||||
// standalone screen is open. observeChannel alone does NOT do this for a relay group
|
||||
// (its finder only handles public-chat / live-activity channels), so without this the
|
||||
// screen would only show whatever the chat screen happened to cache.
|
||||
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
|
||||
|
||||
// Recompose when the relay-signed roster (39001/39002) changes.
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
val iCanModerate = channel.membershipOf(myPubkey).canModerate()
|
||||
val iAmAdmin = channel.membershipOf(myPubkey) == RelayGroupMembership.ADMIN
|
||||
|
||||
// Admins/moderators first, then plain members; alphabetical only inside a rank
|
||||
// is overkill here — rely on relay order but push elevated roles to the top.
|
||||
val roster =
|
||||
remember(channel.admins, channel.members) {
|
||||
val everyone = (channel.admins.map { it.pubKey } + channel.members).distinct()
|
||||
everyone
|
||||
.map { RosterEntry(it, channel.membershipOf(it)) }
|
||||
.sortedBy { it.membership.rank() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_members_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (roster.isEmpty()) {
|
||||
// The roster always has at least the group's admins once it loads, so an
|
||||
// empty list means the kind-39001/39002 events haven't arrived yet.
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(padding)) {
|
||||
itemsIndexed(roster, key = { _, entry -> entry.pubkey }) { index, entry ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
RelayGroupMemberRow(
|
||||
entry = entry,
|
||||
channel = channel,
|
||||
isSelf = entry.pubkey == myPubkey,
|
||||
viewerCanModerate = iCanModerate,
|
||||
viewerIsAdmin = iAmAdmin,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sort key so admins float above moderators above plain members. */
|
||||
private fun RelayGroupMembership.rank(): Int =
|
||||
when (this) {
|
||||
RelayGroupMembership.ADMIN -> 0
|
||||
RelayGroupMembership.MODERATOR -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupMemberRow(
|
||||
entry: RosterEntry,
|
||||
channel: RelayGroupChannel,
|
||||
isSelf: Boolean,
|
||||
viewerCanModerate: Boolean,
|
||||
viewerIsAdmin: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Create-or-get (never a one-shot null): UsernameDisplay observes the user's
|
||||
// metadata flow, so the name fills in when the kind:0 arrives instead of being
|
||||
// stuck on truncated hex forever.
|
||||
val user = remember(entry.pubkey) { accountViewModel.checkGetOrCreateUser(entry.pubkey) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
if (user != null) {
|
||||
UsernameDisplay(user, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = entry.pubkey.take(8),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MemberRoleBadge(entry.membership)
|
||||
|
||||
// Moderators can act on others (not themselves); the relay is the final
|
||||
// authority, but hide obviously-useless menus (a moderator can't touch an admin).
|
||||
val canActOnTarget =
|
||||
viewerCanModerate &&
|
||||
!isSelf &&
|
||||
(viewerIsAdmin || entry.membership != RelayGroupMembership.ADMIN)
|
||||
|
||||
if (canActOnTarget) {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
contentDescription = stringRes(R.string.more_options),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.relay_group_make_admin)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN))
|
||||
},
|
||||
)
|
||||
}
|
||||
if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.relay_group_make_moderator)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR))
|
||||
},
|
||||
)
|
||||
}
|
||||
if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.relay_group_demote_member)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.putRelayGroupUser(channel, entry.pubkey, emptyList())
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_remove_user),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
confirmRemove = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRemove) {
|
||||
val displayName = user?.toBestDisplayName() ?: entry.pubkey.take(8)
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRemove = false },
|
||||
title = { Text(stringRes(R.string.relay_group_remove_user)) },
|
||||
text = { Text(stringRes(R.string.relay_group_remove_user_confirm, displayName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmRemove = false
|
||||
accountViewModel.removeRelayGroupUser(channel, entry.pubkey)
|
||||
}) {
|
||||
Text(stringRes(R.string.relay_group_remove_user), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmRemove = false }) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A small colored pill for an elevated role; plain members get nothing. */
|
||||
@Composable
|
||||
private fun MemberRoleBadge(membership: RelayGroupMembership) {
|
||||
val label =
|
||||
when (membership) {
|
||||
RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin)
|
||||
RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator)
|
||||
else -> return
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* Create a new NIP-29 group on [relayUrl]: publishes kind 9007 + 9002 with every
|
||||
* metadata field the user sets (name, description, picture, and the four status
|
||||
* flags), then opens the new group. The user becomes its first admin.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupCreateScreen(
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
|
||||
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl")
|
||||
LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) }
|
||||
|
||||
// A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as
|
||||
// ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation
|
||||
// on the relay advertising NIP-29 in its NIP-11 `supported_nips`. Tri-state: null = still checking,
|
||||
// false = confirmed unsupported (or unreachable), true = advertised.
|
||||
val nip29Support by produceState<Boolean?>(initialValue = null, relay) {
|
||||
Amethyst.instance.nip11Cache.loadRelayInfo(
|
||||
relay = relay,
|
||||
onInfo = { info -> value = info.supported_nips?.any { it == "29" } ?: false },
|
||||
onError = { _, _, _ -> value = false },
|
||||
)
|
||||
}
|
||||
|
||||
RelayGroupMetadataScaffold(
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
nip29Support = nip29Support,
|
||||
onSuccess = { nav.popUpTo(Route.RelayGroup(viewModel.groupId, relay.url), Route.RelayGroupCreate::class) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a group's relay-signed metadata (kind 9002, admin only). Pre-fills from the
|
||||
* group's current metadata reactively (so a late first load still populates), then
|
||||
* saves and returns.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupEditScreen(
|
||||
id: String,
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
|
||||
val groupId = remember(id, relay) { GroupId(id, relay) }
|
||||
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupEdit:${groupId.toKey()}")
|
||||
|
||||
LoadRelayGroupChannel(groupId, accountViewModel) { channel ->
|
||||
// Keep the relay-signed metadata fresh while editing so a late load prefills.
|
||||
RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
|
||||
|
||||
val channelState by channel
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val liveChannel = channelState.channel as? RelayGroupChannel ?: channel
|
||||
|
||||
LaunchedEffect(liveChannel.groupId) { viewModel.initEdit(accountViewModel, liveChannel) }
|
||||
// Re-seed when the metadata event changes, unless the user has already edited.
|
||||
LaunchedEffect(liveChannel.event?.id) { viewModel.prefillFrom(liveChannel) }
|
||||
|
||||
RelayGroupMetadataScaffold(
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onSuccess = { nav.popBack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupMetadataScaffold(
|
||||
viewModel: RelayGroupMetadataViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onSuccess: () -> Unit,
|
||||
// Whether the target relay advertises NIP-29. null = still checking, false = confirmed
|
||||
// unsupported (block + warn), true = supported. Only meaningful when creating; editing an
|
||||
// existing group already implies a working relay, so callers pass true.
|
||||
nip29Support: Boolean? = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var wantsToPickImage by remember { mutableStateOf(false) }
|
||||
if (wantsToPickImage) {
|
||||
GallerySelectSingle(
|
||||
onImageUri = { media ->
|
||||
wantsToPickImage = false
|
||||
if (media != null) viewModel.pickMedia(media)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val onSubmit: () -> Unit = {
|
||||
viewModel.submit(
|
||||
context = context,
|
||||
onSuccess = onSuccess,
|
||||
onError = accountViewModel.toastManager::toast,
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (viewModel.isNewGroup) {
|
||||
CreatingTopBar(
|
||||
titleRes = R.string.relay_group_create_title,
|
||||
isActive = { viewModel.canPost && nip29Support == true },
|
||||
onCancel = nav::popBack,
|
||||
onPost = onSubmit,
|
||||
)
|
||||
} else {
|
||||
SavingTopBar(
|
||||
titleRes = R.string.relay_group_edit_title,
|
||||
isActive = viewModel::canPost,
|
||||
onCancel = nav::popBack,
|
||||
onPost = onSubmit,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
if (nip29Support == false) {
|
||||
NoNip29Warning()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
GroupImagePicker(viewModel) { wantsToPickImage = true }
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
GroupMetadataFields(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A warning shown when the target relay doesn't advertise NIP-29, blocking creation. */
|
||||
@Composable
|
||||
private fun NoNip29Warning() {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_relay_no_nip29),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The tap-to-upload group avatar hero at the top of the form. */
|
||||
@Composable
|
||||
private fun GroupImagePicker(
|
||||
viewModel: RelayGroupMetadataViewModel,
|
||||
onPick: () -> Unit,
|
||||
) {
|
||||
val picked = viewModel.pickedMedia
|
||||
val currentUrl = viewModel.picture.value.text
|
||||
val model: Any? = picked?.uri ?: currentUrl.ifBlank { null }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (model != null) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = stringRes(R.string.relay_group_field_picture),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(128.dp)
|
||||
.clip(CircleShape)
|
||||
.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.clickable(onClick = onPick),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(128.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
|
||||
.clickable(onClick = onPick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AddPhotoAlternate,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text =
|
||||
if (model != null) {
|
||||
stringRes(R.string.relay_group_change_photo)
|
||||
} else {
|
||||
stringRes(R.string.relay_group_add_photo)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onPick),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.name.value,
|
||||
onValueChange = {
|
||||
viewModel.name.value = it
|
||||
viewModel.markTouched()
|
||||
},
|
||||
singleLine = true,
|
||||
label = { Text(stringRes(R.string.relay_group_field_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = viewModel.about.value,
|
||||
onValueChange = {
|
||||
viewModel.about.value = it
|
||||
viewModel.markTouched()
|
||||
},
|
||||
label = { Text(stringRes(R.string.relay_group_field_about)) },
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_section_discovery),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_section_discovery_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = viewModel.topics.value,
|
||||
onValueChange = {
|
||||
viewModel.topics.value = it
|
||||
viewModel.markTouched()
|
||||
},
|
||||
singleLine = true,
|
||||
label = { Text(stringRes(R.string.relay_group_field_topics)) },
|
||||
placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = viewModel.geohash.value,
|
||||
onValueChange = {
|
||||
viewModel.geohash.value = it
|
||||
viewModel.markTouched()
|
||||
},
|
||||
singleLine = true,
|
||||
label = { Text(stringRes(R.string.relay_group_field_geohash)) },
|
||||
placeholder = { Text(stringRes(R.string.relay_group_field_geohash_hint)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_section_permissions),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
LabeledSwitchRow(
|
||||
label = stringRes(R.string.relay_group_flag_private),
|
||||
description = stringRes(R.string.relay_group_flag_private_desc),
|
||||
checked = viewModel.isPrivate,
|
||||
) {
|
||||
viewModel.isPrivate = it
|
||||
viewModel.markTouched()
|
||||
}
|
||||
LabeledSwitchRow(
|
||||
label = stringRes(R.string.relay_group_flag_invite_only),
|
||||
description = stringRes(R.string.relay_group_flag_invite_only_desc),
|
||||
checked = viewModel.isClosed,
|
||||
) {
|
||||
viewModel.isClosed = it
|
||||
viewModel.markTouched()
|
||||
}
|
||||
LabeledSwitchRow(
|
||||
label = stringRes(R.string.relay_group_flag_restricted),
|
||||
description = stringRes(R.string.relay_group_flag_restricted_desc),
|
||||
checked = viewModel.isRestricted,
|
||||
) {
|
||||
viewModel.isRestricted = it
|
||||
viewModel.markTouched()
|
||||
}
|
||||
LabeledSwitchRow(
|
||||
label = stringRes(R.string.relay_group_flag_hidden),
|
||||
description = stringRes(R.string.relay_group_flag_hidden_desc),
|
||||
checked = viewModel.isHidden,
|
||||
) {
|
||||
viewModel.isHidden = it
|
||||
viewModel.markTouched()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabeledSwitchRow(
|
||||
label: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onChange)
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Backs the create/edit NIP-29 group metadata screens. Holds the full editable metadata
|
||||
* (name, about, picture, and the four status flags), lets the user pick a picture from the
|
||||
* gallery, uploads it to their configured media server on submit, then publishes the kind
|
||||
* 9007+9002 (create) or 9002 (edit) events. Mirrors [EmojiPackMetadataViewModel].
|
||||
*/
|
||||
@Stable
|
||||
class RelayGroupMetadataViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
/** Non-null in edit mode; null while creating a new group. */
|
||||
private var channel: RelayGroupChannel? = null
|
||||
val isNewGroup by derivedStateOf { channel == null }
|
||||
|
||||
/** Host relay (create + edit) and the group id (generated in create mode). */
|
||||
var relay: NormalizedRelayUrl? = null
|
||||
private set
|
||||
var groupId: String = ""
|
||||
private set
|
||||
|
||||
val name = mutableStateOf(TextFieldValue())
|
||||
val about = mutableStateOf(TextFieldValue())
|
||||
val picture = mutableStateOf(TextFieldValue())
|
||||
|
||||
/** Comma/space-separated topic hashtags; drives the discovery hashtag filter. */
|
||||
val topics = mutableStateOf(TextFieldValue())
|
||||
|
||||
/** A single geohash for the group's location; drives the discovery geo filter. */
|
||||
val geohash = mutableStateOf(TextFieldValue())
|
||||
|
||||
var isPrivate by mutableStateOf(false)
|
||||
var isClosed by mutableStateOf(false)
|
||||
var isHidden by mutableStateOf(false)
|
||||
var isRestricted by mutableStateOf(false)
|
||||
|
||||
var pickedMedia by mutableStateOf<SelectedMedia?>(null)
|
||||
private set
|
||||
|
||||
var isWorking by mutableStateOf(false)
|
||||
|
||||
/** Set once the user edits any field, so late-arriving metadata won't clobber their input. */
|
||||
var touched by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val canPost by derivedStateOf { !isWorking && name.value.text.isNotBlank() }
|
||||
|
||||
fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank()
|
||||
|
||||
fun initCreate(
|
||||
accountViewModel: AccountViewModel,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
this.account = accountViewModel.account
|
||||
if (this.relay == null) {
|
||||
this.relay = relay
|
||||
// Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada).
|
||||
this.groupId = RandomInstance.bytes(8).toHexKey()
|
||||
}
|
||||
}
|
||||
|
||||
fun initEdit(
|
||||
accountViewModel: AccountViewModel,
|
||||
channel: RelayGroupChannel,
|
||||
) {
|
||||
this.account = accountViewModel.account
|
||||
this.channel = channel
|
||||
this.relay = channel.groupId.relayUrl
|
||||
this.groupId = channel.groupId.id
|
||||
}
|
||||
|
||||
/** Seed the fields from the group's current relay-signed metadata, unless the user has edited. */
|
||||
fun prefillFrom(channel: RelayGroupChannel) {
|
||||
if (touched) return
|
||||
val event = channel.event
|
||||
name.value = TextFieldValue(event?.name() ?: "")
|
||||
about.value = TextFieldValue(event?.about() ?: "")
|
||||
picture.value = TextFieldValue(event?.picture() ?: "")
|
||||
isPrivate = channel.isPrivate()
|
||||
isClosed = channel.isClosed()
|
||||
isHidden = event?.isHidden() ?: false
|
||||
isRestricted = event?.isRestricted() ?: false
|
||||
topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "")
|
||||
// Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one.
|
||||
geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "")
|
||||
}
|
||||
|
||||
/** Split the topics field into distinct, non-blank, lowercased hashtags (leading `#` dropped). */
|
||||
private fun parseTopics(): List<String> =
|
||||
topics.value.text
|
||||
.split(',', ' ', '\n', '\t')
|
||||
.map { it.trim().removePrefix("#").lowercase() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
/** The single geohash (lowercased, `#` stripped), or empty when none. */
|
||||
private fun parseGeohashes(): List<String> =
|
||||
geohash.value.text
|
||||
.trim()
|
||||
.removePrefix("#")
|
||||
.lowercase()
|
||||
.ifBlank { null }
|
||||
?.let { listOf(it) }
|
||||
?: emptyList()
|
||||
|
||||
fun markTouched() {
|
||||
touched = true
|
||||
}
|
||||
|
||||
fun pickMedia(media: SelectedMedia) {
|
||||
pickedMedia = media
|
||||
touched = true
|
||||
}
|
||||
|
||||
fun submit(
|
||||
context: Context,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
if (isWorking) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
isWorking = true
|
||||
try {
|
||||
val local = pickedMedia
|
||||
if (local != null) {
|
||||
val uploadedUrl = uploadImage(local, context, onError) ?: return@launch
|
||||
picture.value = TextFieldValue(uploadedUrl)
|
||||
pickedMedia = null
|
||||
}
|
||||
|
||||
try {
|
||||
publish()
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
onSuccess()
|
||||
} finally {
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publish() {
|
||||
val name = name.value.text.trim()
|
||||
val about =
|
||||
about.value.text
|
||||
.trim()
|
||||
.ifBlank { null }
|
||||
val picture =
|
||||
picture.value.text
|
||||
.trim()
|
||||
.ifBlank { null }
|
||||
val hashtags = parseTopics()
|
||||
val geohashes = parseGeohashes()
|
||||
val existing = channel
|
||||
if (existing == null) {
|
||||
account.createRelayGroup(
|
||||
relay = relay!!,
|
||||
groupId = groupId,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
isPrivate = isPrivate,
|
||||
isClosed = isClosed,
|
||||
isHidden = isHidden,
|
||||
isRestricted = isRestricted,
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
)
|
||||
} else {
|
||||
account.editRelayGroupMetadata(
|
||||
channel = existing,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
isPrivate = isPrivate,
|
||||
isClosed = isClosed,
|
||||
isHidden = isHidden,
|
||||
isRestricted = isRestricted,
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadImage(
|
||||
galleryUri: SelectedMedia,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
): String? {
|
||||
val sourceUri =
|
||||
try {
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
return null
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
} catch (e: AvifMetadataNotVerifiableException) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
|
||||
)
|
||||
return null
|
||||
}
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
return try {
|
||||
val result =
|
||||
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||
Nip96Uploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
onProgress = {},
|
||||
httpAuth = account::createHTTPAuthorization,
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
BlossomUploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
httpAuth = account::createBlossomUploadAuth,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
result.url ?: run {
|
||||
onError(
|
||||
stringRes(context, R.string.failed_to_upload_media_no_details),
|
||||
stringRes(context, R.string.server_did_not_provide_a_url_after_uploading),
|
||||
)
|
||||
null
|
||||
}
|
||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.failed_to_upload_media_no_details),
|
||||
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload),
|
||||
)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
/** One relay row: NIP-11 avatar + name, used by the browse screen. */
|
||||
@Composable
|
||||
fun RelayGroupServerRow(
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
|
||||
val host = relay?.displayUrl() ?: relayUrl
|
||||
val info = relay?.let { loadRelayInfo(it) }
|
||||
val name = info?.value?.name?.takeIf { it.isNotBlank() } ?: host
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RenderRelayIcon(
|
||||
displayUrl = host,
|
||||
iconUrl = info?.value?.icon,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
pingInMs = 0,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (name != host) {
|
||||
Text(
|
||||
text = host,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
|
||||
/**
|
||||
* A group's forum-style threads (kind 11) — the secondary content type kept out of
|
||||
* the kind-9 chat feed. Streams the group's threads + their comments via
|
||||
* [RelayGroupThreadFeedSubscription]; tapping a thread opens the generic thread view
|
||||
* ([Route.Note]) with its comment tree. Members can start a new thread.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupThreadsScreen(
|
||||
id: HexKey,
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
|
||||
val channelId = remember(id, relay) { GroupId(id, relay) }
|
||||
|
||||
LoadRelayGroupChannel(channelId, accountViewModel) { channel ->
|
||||
RelayGroupThreads(channel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupThreads(
|
||||
channel: RelayGroupChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RelayGroupThreadFeedSubscription(channel, accountViewModel.dataSources().relayGroupThreadFeed, accountViewModel)
|
||||
|
||||
val threads by channel.threads.collectAsStateWithLifecycle()
|
||||
|
||||
// Only members can post a thread (the relay rejects a non-member's kind-11), so the
|
||||
// compose FAB is hidden for everyone else.
|
||||
val canPost = channel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_threads_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (canPost) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
nav.nav(
|
||||
Route.NewShortNote(
|
||||
groupThreadId = channel.groupId.id,
|
||||
groupThreadRelayUrl = channel.groupId.relayUrl.url,
|
||||
),
|
||||
)
|
||||
},
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(R.string.relay_group_thread_new),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (threads.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_threads_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(32.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(padding)) {
|
||||
itemsIndexed(threads, key = { _, thread -> thread.idHex }) { index, thread ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThreadRow(
|
||||
thread: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// Observe the reply count so a kind-1111 comment arriving on an already-listed thread
|
||||
// bumps it live (channel.threads only re-emits on add/remove of a thread).
|
||||
val replyCount by observeNoteReplyCount(thread, accountViewModel)
|
||||
val event = thread.event as? ThreadEvent
|
||||
val title = event?.title()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.relay_group_thread_untitled)
|
||||
val preview =
|
||||
event
|
||||
?.content
|
||||
?.replace('\n', ' ')
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
val author = thread.author
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (author != null) {
|
||||
UserPicture(author, Size35dp, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (preview.isNotEmpty()) {
|
||||
Text(
|
||||
text = preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (author != null) {
|
||||
UsernameDisplay(author, accountViewModel = accountViewModel)
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Chat,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Text(
|
||||
text = "$replyCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.njumpLink
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
@Composable
|
||||
fun RelayGroupTopBar(
|
||||
baseChannel: RelayGroupChannel,
|
||||
inviteCode: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Recompose when the relay-signed metadata / roster changes.
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
val membership = channel.membershipOf(myPubkey)
|
||||
val memberCount = channel.memberCount()
|
||||
|
||||
// Optimistic "requested" state: set on Join, cleared once the relay's roster
|
||||
// shows us as a member. Never persisted — a fresh visit reads the relay truth.
|
||||
var requested by remember(channel.groupId) { mutableStateOf(false) }
|
||||
LaunchedEffect(membership) {
|
||||
if (membership.isMember()) requested = false
|
||||
}
|
||||
|
||||
// Arrived via an invite link carrying a join code (`wss://relay'id?code=…`): the
|
||||
// user already opted in by tapping it, so fire the kind-9021 join once (with the
|
||||
// code) instead of making them re-enter it. Guarded to exactly once and skipped if
|
||||
// the roster already shows us as a member.
|
||||
var autoJoined by remember(channel.groupId) { mutableStateOf(false) }
|
||||
LaunchedEffect(channel.groupId, inviteCode, membership) {
|
||||
if (inviteCode != null && !autoJoined && !membership.isMember()) {
|
||||
autoJoined = true
|
||||
requested = true
|
||||
accountViewModel.joinRelayGroup(channel, inviteCode)
|
||||
}
|
||||
}
|
||||
val displayMembership = if (!membership.isMember() && requested) RelayGroupMembership.PENDING else membership
|
||||
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var showInvite by remember { mutableStateOf(false) }
|
||||
var showJoinCode by remember { mutableStateOf(false) }
|
||||
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
RoleBadge(displayMembership)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (channel.isPrivate()) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = channel.groupId.relayUrl.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (memberCount > 0) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Group,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
Text(
|
||||
text = "$memberCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url))
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Forum,
|
||||
contentDescription = stringRes(R.string.relay_group_threads_title),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
val naddr = channel.toNAddr()
|
||||
if (naddr != null) {
|
||||
val context = LocalContext.current
|
||||
IconButton(onClick = { shareRelayGroup(context, naddr) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
displayMembership == RelayGroupMembership.PENDING -> {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_pending),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
!displayMembership.isMember() -> {
|
||||
FilledTonalButton(onClick = {
|
||||
// Closed groups need an invite code; open groups join directly.
|
||||
if (channel.isClosed()) {
|
||||
showJoinCode = true
|
||||
} else {
|
||||
requested = true
|
||||
accountViewModel.joinRelayGroup(channel)
|
||||
}
|
||||
}) {
|
||||
Text(stringRes(R.string.join))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
contentDescription = stringRes(R.string.more_options),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.relay_group_menu_members)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
nav.nav(Route.RelayGroupMembers(channel.groupId.id, channel.groupId.relayUrl.url))
|
||||
},
|
||||
)
|
||||
if (displayMembership == RelayGroupMembership.ADMIN) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.relay_group_menu_edit)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
nav.nav(Route.RelayGroupEdit(channel.groupId.id, channel.groupId.relayUrl.url))
|
||||
},
|
||||
)
|
||||
}
|
||||
if (displayMembership.canModerate()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.relay_group_invite_title)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
showInvite = true
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.leave)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.leaveRelayGroup(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
|
||||
if (showInvite) {
|
||||
InviteRelayGroupDialog(channel, accountViewModel) { showInvite = false }
|
||||
}
|
||||
|
||||
if (showJoinCode) {
|
||||
JoinRelayGroupDialog(
|
||||
channel = channel,
|
||||
accountViewModel = accountViewModel,
|
||||
onJoined = { requested = true },
|
||||
onDismiss = { showJoinCode = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the system share sheet with a njump web link to the group's naddr. */
|
||||
private fun shareRelayGroup(
|
||||
context: Context,
|
||||
naddr: String,
|
||||
) {
|
||||
val sendIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, njumpLink(naddr))
|
||||
putExtra(Intent.EXTRA_TITLE, stringRes(context, R.string.quick_action_share_browser_link))
|
||||
}
|
||||
context.startActivity(Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)))
|
||||
}
|
||||
|
||||
/** A small colored pill naming the user's role/status in the group. */
|
||||
@Composable
|
||||
private fun RoleBadge(membership: RelayGroupMembership) {
|
||||
val label =
|
||||
when (membership) {
|
||||
RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin)
|
||||
RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator)
|
||||
RelayGroupMembership.PENDING -> stringRes(R.string.relay_group_pending)
|
||||
// A plain member needs no badge; the lack of a Join button already says it.
|
||||
RelayGroupMembership.MEMBER, RelayGroupMembership.NONE -> return
|
||||
}
|
||||
|
||||
val container =
|
||||
if (membership == RelayGroupMembership.ADMIN || membership == RelayGroupMembership.MODERATOR) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
}
|
||||
val content =
|
||||
if (membership == RelayGroupMembership.ADMIN || membership == RelayGroupMembership.MODERATOR) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
}
|
||||
|
||||
Surface(shape = RoundedCornerShape(6.dp), color = container) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = content,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint] (the shared,
|
||||
* platform-agnostic matcher lives in `:commons`). The relays are exactly the relays the filter
|
||||
* routes to; each relay's constraint carries only that relay's slice of the follow/topic/geo set
|
||||
* (matching how the note feeds shard per relay). Community selections have no people/topic/geo
|
||||
* dimension, so they fall back to [GroupDiscoveryConstraint.AllGroups] — still constraining the
|
||||
* RELAY set, just not the people. Each branch mirrors what the datasource actually REQs for that
|
||||
* filter, so fetch and display agree.
|
||||
*/
|
||||
fun IFeedTopNavPerRelayFilterSet.toGroupConstraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> =
|
||||
when (this) {
|
||||
is GlobalTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
|
||||
is RelayTopNavPerRelayFilterSet -> mapOf(relayUrl to GroupDiscoveryConstraint.AllGroups)
|
||||
is AuthorsTopNavPerRelayFilterSet ->
|
||||
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) }
|
||||
is HashtagTopNavPerRelayFilterSet ->
|
||||
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByHashtags(f.hashtags) }
|
||||
is LocationTopNavPerRelayFilterSet ->
|
||||
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByGeohashes(f.geotags) }
|
||||
is AllFollowsTopNavPerRelayFilterSet ->
|
||||
set.mapValues { (_, f) ->
|
||||
val lenses =
|
||||
buildList {
|
||||
f.authors?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByPeople(it)) }
|
||||
f.hashtags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByHashtags(it)) }
|
||||
f.geotags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByGeohashes(it)) }
|
||||
}
|
||||
if (lenses.isEmpty()) GroupDiscoveryConstraint.AllGroups else GroupDiscoveryConstraint.AnyOf(lenses)
|
||||
}
|
||||
// Muted-authors is a "show me the muted" lens: keep groups where a muted user is the relay
|
||||
// key / an admin / a member (matching the #p roster REQ that fetches them).
|
||||
is MutedAuthorsTopNavPerRelayFilterSet ->
|
||||
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) }
|
||||
// Community selections carry no group-hosting dimension; show every group the resolved
|
||||
// relays host (matching the broad directory REQ). Empty for DVM/anything with no relays.
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
|
||||
else -> emptyMap()
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
/**
|
||||
* Resolves a relay-signed kind-39000 metadata note to the live [RelayGroupChannel] it belongs to.
|
||||
* A group is keyed by (host relay + group id); the SAME resolver is used by the feed filter's
|
||||
* match/sort AND the discovery row's renderer, so a note seen on more than one relay always binds
|
||||
* to one channel (no more "sorted by relay B's roster, rendered with relay A's empty one").
|
||||
*/
|
||||
fun relayGroupDiscoveryChannelFor(note: Note): RelayGroupChannel? {
|
||||
val groupId = (note.event as? GroupMetadataEvent)?.groupId() ?: return null
|
||||
return note.relays.firstNotNullOfOrNull { LocalCache.getRelayGroupChannelIfExists(GroupId(groupId, it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Relay Groups discovery feed. Rows are the relay-signed kind-39000 metadata notes; the
|
||||
* top-nav filter is resolved into a per-relay [GroupDiscoveryConstraint] ([toGroupConstraints])
|
||||
* that decides which groups qualify: every group for Global, relay-key / admin / member for the
|
||||
* people filters (rosters read from the group's [RelayGroupChannel]), or a `#t`/`#g` tag match
|
||||
* for topic/geo filters. Structurally identical to
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal.GitRepositoriesFeedFilter];
|
||||
* the only difference is that a group's author is its relay, so the match runs through the
|
||||
* roster-aware constraint rather than the author-based [com.vitorpamplona.amethyst.ui.dal.FilterByListParams].
|
||||
*/
|
||||
class RelayGroupDiscoveryFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
/**
|
||||
* The feed identity is the selected list PLUS a fingerprint of what the feed actually filters
|
||||
* on. The list flips synchronously when the spinner changes, but the per-relay set it resolves
|
||||
* to ([liveRelayGroupsDiscoveryFollowListsPerRelay]) lands a frame later (the outbox loader is
|
||||
* async). If the key were the list alone, the first refresh would run against the STALE set
|
||||
* (showing the old filter's groups) and the catch-up emission — same list code — would be
|
||||
* swallowed by [checkKeysInvalidateDataAndSendToTop]'s "key unchanged" guard, freezing the feed
|
||||
* on the previous filter until a manual pull-to-refresh. Folding the resolved discriminator in
|
||||
* (the joined ids for "My Groups", the per-relay constraints otherwise — both content-hashed via
|
||||
* data classes) makes the key move when the resolution lands, so the refresh actually fires.
|
||||
*/
|
||||
override fun feedKey(): String =
|
||||
account.userProfile().pubkeyHex + "-" + followList().code + "-" +
|
||||
(if (isMine()) joinedGroupIds().hashCode() else constraints().hashCode())
|
||||
|
||||
override fun limit() = 200
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value
|
||||
|
||||
/**
|
||||
* "My Groups" = every group I'm participating in. That's the UNION of:
|
||||
* - the groups I explicitly joined (my kind-10009 list) — authoritative and immediate, even
|
||||
* before the relay adds me to its roster or for open groups it doesn't roster; and
|
||||
* - any group whose relay-signed roster (39001/39002) lists me as an admin/member (e.g. an
|
||||
* admin added me, or I was rostered on a relay I haven't listed in kind 10009).
|
||||
*
|
||||
* These live on their host relays (from kind 10009), not my outbox, so the standard
|
||||
* [TopFilter.Mine] relay-set resolution wouldn't reach them. The joined groups' metadata +
|
||||
* rosters are kept in cache by RelayGroupMyJoinedGroupsSubscription mounted on the screen.
|
||||
*/
|
||||
private fun isMine(): Boolean = followList() is TopFilter.Mine
|
||||
|
||||
/** The (relay + id) of every group in my kind-10009 joined list. */
|
||||
private fun joinedGroupIds(): Set<GroupId> =
|
||||
account.relayGroupList.liveRelayGroupList.value
|
||||
.mapNotNullTo(HashSet()) { tag ->
|
||||
RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { GroupId(tag.groupId, it) }
|
||||
}
|
||||
|
||||
private fun isMyGroup(
|
||||
channel: RelayGroupChannel,
|
||||
joined: Set<GroupId>,
|
||||
): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember()
|
||||
|
||||
private fun constraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
if (isMine()) return sort(myGroupNotes())
|
||||
val byRelay = constraints()
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet(GroupMetadataEvent.KIND) { _, note ->
|
||||
matches(note, byRelay)
|
||||
}
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
private fun myGroupNotes(): Set<Note> {
|
||||
val joined = joinedGroupIds()
|
||||
val notes = HashSet<Note>()
|
||||
// Groups I explicitly joined — resolve each to its metadata note (may be null until loaded).
|
||||
joined.forEach { LocalCache.getRelayGroupChannelIfExists(it)?.metadataNote?.let(notes::add) }
|
||||
// Plus any group whose roster lists me as an admin/member.
|
||||
val me = account.userProfile().pubkeyHex
|
||||
LocalCache.relayGroupChannels
|
||||
.filter { _, channel -> channel.event != null && channel.membershipOf(me).isMember() }
|
||||
.forEach { it.metadataNote?.let(notes::add) }
|
||||
return notes
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
val byRelay = constraints()
|
||||
return newItems.flatMapTo(HashSet()) { note ->
|
||||
when (note.event) {
|
||||
is GroupMetadataEvent -> if (matches(note, byRelay)) listOf(note) else emptyList()
|
||||
// A roster change (39001/39002) can newly qualify a group whose 39000 already
|
||||
// arrived (a follow just became an admin/member). The roster note itself isn't a
|
||||
// feed row, so re-test the group's metadata note and inject it if it now matches —
|
||||
// otherwise the group would stay hidden (or frozen at 0 members) until a refresh.
|
||||
is GroupAdminsEvent, is GroupMembersEvent ->
|
||||
rosterMetadataNote(note)?.takeIf { matches(it, byRelay) }?.let { listOf(it) } ?: emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The group qualifies when its host relay is in the filter's set AND that relay's constraint
|
||||
* accepts it. [relayGroupDiscoveryChannelFor] picks the one channel (metadata + roster); the
|
||||
* roster lives on the [RelayGroupChannel] [LocalCache.consume] created when the metadata arrived.
|
||||
*/
|
||||
private fun matches(
|
||||
note: Note,
|
||||
byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>,
|
||||
): Boolean {
|
||||
val channel = relayGroupDiscoveryChannelFor(note) ?: return false
|
||||
if (isMine()) return isMyGroup(channel, joinedGroupIds())
|
||||
if (byRelay.isEmpty()) return false
|
||||
// Only surface groups whose 39000 is actually signed by the host relay's key (NIP-29's
|
||||
// authority). This drops stray user-published 39000s stored on general relays — the ones
|
||||
// with no roster that can't be joined — even when the relay itself is otherwise queried.
|
||||
if (!isRelaySignedRelayGroup(channel)) return false
|
||||
return byRelay[channel.groupId.relayUrl]?.matches(channel) == true
|
||||
}
|
||||
|
||||
/** The 39000 metadata note for the group a roster (39001/39002) note belongs to, if cached. */
|
||||
private fun rosterMetadataNote(note: Note): Note? {
|
||||
val groupId =
|
||||
when (val event = note.event) {
|
||||
is GroupAdminsEvent -> event.groupId()
|
||||
is GroupMembersEvent -> event.groupId()
|
||||
else -> null
|
||||
} ?: return null
|
||||
return note.relays
|
||||
.firstNotNullOfOrNull { LocalCache.getRelayGroupChannelIfExists(GroupId(groupId, it)) }
|
||||
?.metadataNote
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> {
|
||||
// Snapshot the member count once per note (stable key), then order by it descending with
|
||||
// the shared default feed order (createdAt snapshot + id) as a stable tie-break. Reading
|
||||
// createdAt live inside a comparator risks TimSort's "contract violated" crash when a
|
||||
// newer 39000 replaces a note's event mid-sort — sortedByDefaultFeedOrder avoids that.
|
||||
val memberCount = items.associateWith { relayGroupDiscoveryChannelFor(it)?.memberCount() ?: 0 }
|
||||
return items.sortedByDefaultFeedOrder().sortedByDescending { memberCount[it] }
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
/** One screen's request to keep the roster of the user's joined groups fresh. */
|
||||
class RelayGroupMyJoinedGroupsQueryState(
|
||||
val account: Account,
|
||||
)
|
||||
|
||||
/**
|
||||
* Roster kinds only (39000 metadata + 39001 admins + 39002 members) — enough to
|
||||
* resolve name, member count and this user's membership. Roles (39003) are pulled
|
||||
* by the per-chat / directory subscriptions when actually needed.
|
||||
*/
|
||||
private val RELAY_GROUP_ROSTER_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Keeps the relay-signed roster (metadata/admins/members) of every group the user
|
||||
* has joined live while a groups-bearing screen is on top, so membership,
|
||||
* pending→member transitions and member counts stay accurate in list views
|
||||
* without having to open each chat. This matters most for closed/private groups,
|
||||
* where the only way to confirm a join was admitted is a fresh 39002.
|
||||
*
|
||||
* One subscription per host relay, scoped by `#d` to just that relay's joined
|
||||
* group ids — so we don't pull a relay's whole directory, only what we're in.
|
||||
*/
|
||||
class RelayGroupMyJoinedGroupsFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<RelayGroupMyJoinedGroupsQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
RelayGroupMyJoinedGroupsSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
|
||||
class RelayGroupMyJoinedGroupsSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RelayGroupMyJoinedGroupsQueryState>,
|
||||
) : PerUniqueIdEoseManager<RelayGroupMyJoinedGroupsQueryState, Account>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: RelayGroupMyJoinedGroupsQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
val joined = key.account.relayGroupList.liveRelayGroupList.value
|
||||
if (joined.isEmpty()) return null
|
||||
|
||||
// Group the joined group ids by their host relay: one #d-scoped filter each.
|
||||
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
|
||||
|
||||
return idsByRelay.mapNotNull { (relayUrl, groupIds) ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_ROSTER_KINDS,
|
||||
tags = mapOf("d" to groupIds.distinct()),
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun id(key: RelayGroupMyJoinedGroupsQueryState) = key.account
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Mount on any screen that lists the user's joined groups (the Messages tab's
|
||||
* inline/grouped views, the Relay Groups home) to keep their rosters live.
|
||||
*
|
||||
* The query state is keyed on the account (stable), so the assembler wouldn't
|
||||
* re-run its filter derivation on its own when the joined-group set changes.
|
||||
* We watch [liveRelayGroupList] and invalidate the assembler on every change, so
|
||||
* a join/leave while this screen stays foregrounded immediately re-subscribes to
|
||||
* the new group's roster (critical for confirming admission to a closed group).
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupMyJoinedGroupsSubscription(
|
||||
dataSource: RelayGroupMyJoinedGroupsFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
RelayGroupMyJoinedGroupsQueryState(accountViewModel.account)
|
||||
}
|
||||
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||
.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(joined) { dataSource.invalidateFilters() }
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
|
||||
/** One threads-screen's request for a single group's kind-11 threads. */
|
||||
class RelayGroupThreadFeedQueryState(
|
||||
val channel: RelayGroupChannel,
|
||||
)
|
||||
|
||||
/**
|
||||
* The forum content of a NIP-29 group: kind-11 thread roots plus their kind-1111
|
||||
* comment trees, both scoped by the group's `h` tag and pinned to the host relay.
|
||||
* Active only while a group's Threads screen is open (threads are secondary to the
|
||||
* kind-9 chat, so we don't pay for them until asked). Fetching the comments here
|
||||
* too means opening a thread from the list has its replies already cached.
|
||||
*/
|
||||
class RelayGroupThreadFeedFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<RelayGroupThreadFeedQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
RelayGroupThreadFeedSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
|
||||
class RelayGroupThreadFeedSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RelayGroupThreadFeedQueryState>,
|
||||
) : PerUniqueIdEoseManager<RelayGroupThreadFeedQueryState, GroupId>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: RelayGroupThreadFeedQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val groupId = key.channel.groupId
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND),
|
||||
tags = mapOf("h" to listOf(groupId.id)),
|
||||
since = since?.get(groupId.relayUrl)?.time,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun id(key: RelayGroupThreadFeedQueryState) = key.channel.groupId
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/** Mount on a group's Threads screen to stream its kind-11 threads + 1111 comments. */
|
||||
@Composable
|
||||
fun RelayGroupThreadFeedSubscription(
|
||||
channel: RelayGroupChannel,
|
||||
dataSource: RelayGroupThreadFeedFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(channel.groupId) {
|
||||
RelayGroupThreadFeedQueryState(channel)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.filterMetadataToRelayGroup
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/** One on-screen group card's request to warm a single group. */
|
||||
class RelayGroupWarmupQueryState(
|
||||
val channel: RelayGroupChannel,
|
||||
/** When true, prefetch only recent content — the caller's screen already streams metadata. */
|
||||
val contentOnly: Boolean = false,
|
||||
/** How many recent content events to prefetch ahead of a tap. */
|
||||
val contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT,
|
||||
)
|
||||
|
||||
/** Newest content kinds we prefetch so opening the card lands on populated screens. */
|
||||
private val RELAY_GROUP_WARMUP_CONTENT_KINDS =
|
||||
listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
|
||||
|
||||
/**
|
||||
* Default number of recent events to pull ahead of a tap — enough to fill the first screen AND drive
|
||||
* the discovery card's "50+ messages" activity signal (a chat that returns the full page reads as
|
||||
* "50+"; fewer shows the exact loaded count). Callers that only need a first-screen preview (e.g. a
|
||||
* relay's channel list) pass a smaller [RelayGroupWarmupQueryState.contentLimit].
|
||||
*/
|
||||
const val RELAY_GROUP_WARMUP_LIMIT = 50
|
||||
|
||||
/**
|
||||
* Warms a NIP-29 group referenced inline (a group-link card) without opening it:
|
||||
* keeps the relay-signed metadata (name/picture/about + admins/members/roles) live
|
||||
* so the card fills in and stays current, and prefetches the newest handful of chat
|
||||
* messages and threads so tapping the card lands on already-cached content. Both are
|
||||
* pinned to the group's single host relay. Active only while the card is on-screen.
|
||||
*/
|
||||
class RelayGroupWarmupFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<RelayGroupWarmupQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
RelayGroupWarmupSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
|
||||
class RelayGroupWarmupSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RelayGroupWarmupQueryState>,
|
||||
) : PerUniqueIdEoseManager<RelayGroupWarmupQueryState, GroupId>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: RelayGroupWarmupQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val groupId = key.channel.groupId
|
||||
val metadata = if (key.contentOnly) emptyList() else filterMetadataToRelayGroup(key.channel, since)
|
||||
return metadata +
|
||||
RelayBasedFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_WARMUP_CONTENT_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
limit = key.contentLimit,
|
||||
since = since?.get(groupId.relayUrl)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun id(key: RelayGroupWarmupQueryState) = key.channel.groupId
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Mount on a group card to warm its recent content ahead of a tap. [contentOnly] drops the
|
||||
* metadata/roster filter (39000-39003) — pass true on the discovery feed, where the directory
|
||||
* subscription already streams that for the whole relay, so each visible row asks for content only.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupWarmupSubscription(
|
||||
channel: RelayGroupChannel,
|
||||
dataSource: RelayGroupWarmupFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
contentOnly: Boolean = false,
|
||||
contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT,
|
||||
) {
|
||||
val state =
|
||||
remember(channel.groupId, contentOnly, contentLimit) {
|
||||
RelayGroupWarmupQueryState(channel, contentOnly, contentLimit)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAllCommunities
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByCommunity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByFollows
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByGeohashes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByHashtag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByMutedAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByRelay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsGlobal
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
fun makeRelayGroupsDiscoveryFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
when (feedSettings) {
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterRelayGroupsByAllCommunities(feedSettings, since, defaultSince)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterRelayGroupsByFollows(feedSettings, since, defaultSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterRelayGroupsByAuthors(feedSettings, since, defaultSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterRelayGroupsGlobal(feedSettings, since, defaultSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterRelayGroupsByHashtag(feedSettings, since, defaultSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterRelayGroupsByGeohashes(feedSettings, since, defaultSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterRelayGroupsByMutedAuthors(feedSettings, since, defaultSince)
|
||||
is RelayTopNavPerRelayFilterSet -> filterRelayGroupsByRelay(feedSettings, since, defaultSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterRelayGroupsByCommunity(feedSettings, since, defaultSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class RelayGroupsDiscoveryQueryState(
|
||||
val account: Account,
|
||||
val feedStates: AccountFeedContentStates,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class RelayGroupsDiscoveryFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<RelayGroupsDiscoveryQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
RelayGroupsDiscoverySubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.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)
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.relayGroupChannelsByRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RelayGroupsDiscoverySubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RelayGroupsDiscoveryQueryState>,
|
||||
) : PerUserAndFollowListEoseManager<RelayGroupsDiscoveryQueryState, TopFilter>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: RelayGroupsDiscoveryQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val feedSettings = key.followsPerRelay()
|
||||
val defaultSince = key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtIfFilled()
|
||||
|
||||
val base = makeRelayGroupsDiscoveryFilter(feedSettings, since, defaultSince)
|
||||
|
||||
// The follow-list filter sets resolve their relays via the outbox model (a follow's own
|
||||
// publish relays), but a NIP-29 roster (39001/39002) lives ONLY on the group's host relay.
|
||||
// So a follow who is an admin/member of a group never surfaces unless we also ask that host
|
||||
// relay for `#p:<follows>` rosters. Without this, All-Follows shows nothing until the user
|
||||
// bounces through Global (which pulls the whole directory) — the reported bug.
|
||||
val (follows, alreadyQueried) =
|
||||
when (feedSettings) {
|
||||
is AllFollowsTopNavPerRelayFilterSet ->
|
||||
feedSettings.set.values.flatMapTo(HashSet<HexKey>()) { it.authors.orEmpty() } to feedSettings.set.keys
|
||||
is AuthorsTopNavPerRelayFilterSet ->
|
||||
feedSettings.set.values.flatMapTo(HashSet<HexKey>()) { it.authors } to feedSettings.set.keys
|
||||
else -> return base
|
||||
}
|
||||
if (follows.isEmpty()) return base
|
||||
|
||||
// Query the follows as `#p` against every group-host relay we already know about (joined via
|
||||
// kind-10009 + favorited via kind-10012), minus the relays the outbox filter already covers.
|
||||
val hostRelays = key.groupHostRelays() - alreadyQueried
|
||||
if (hostRelays.isEmpty()) return base
|
||||
|
||||
val channelsByRelay = relayGroupChannelsByRelay()
|
||||
val extra =
|
||||
hostRelays.flatMap { relay ->
|
||||
filterRelayGroupsByAuthors(
|
||||
relay = relay,
|
||||
authors = follows,
|
||||
since = since?.get(relay)?.time ?: defaultSince,
|
||||
cachedChannels = channelsByRelay[relay].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
return base + extra
|
||||
}
|
||||
|
||||
/** Relays that host NIP-29 groups the user is connected to: joined (kind-10009) + favorited (kind-10012). */
|
||||
private fun RelayGroupsDiscoveryQueryState.groupHostRelays(): Set<NormalizedRelayUrl> =
|
||||
buildSet {
|
||||
account.relayGroupList.liveRelayGroupServers.value.forEach { server ->
|
||||
RelayUrlNormalizer.normalizeOrNull(server)?.let(::add)
|
||||
}
|
||||
addAll(account.relayFeedsList.flow.value)
|
||||
}
|
||||
|
||||
override fun user(key: RelayGroupsDiscoveryQueryState) = key.account.userProfile()
|
||||
|
||||
override fun list(key: RelayGroupsDiscoveryQueryState) = key.listName()
|
||||
|
||||
fun RelayGroupsDiscoveryQueryState.listNameFlow() = account.settings.defaultRelayGroupsDiscoveryFollowList
|
||||
|
||||
fun RelayGroupsDiscoveryQueryState.listName() = listNameFlow().value
|
||||
|
||||
fun RelayGroupsDiscoveryQueryState.followsPerRelayFlow() = account.liveRelayGroupsDiscoveryFollowListsPerRelay
|
||||
|
||||
fun RelayGroupsDiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: RelayGroupsDiscoveryQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.listNameFlow().collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.followsPerRelayFlow().sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
// A #p roster hit (39001/39002) discovers a group but carries no 39000, so re-run
|
||||
// the assembly when rosters land — that fires the #d metadata backfill for groups
|
||||
// where a follow is an admin/member but whose metadata isn't cached yet.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.sample(2000).collectLatest { bundle ->
|
||||
if (bundle.any { it.event is GroupAdminsEvent || it.event is GroupMembersEvent }) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
},
|
||||
// Joining/leaving a group or favoriting a relay changes the host-relay set we probe
|
||||
// for follow rosters above, so re-assemble when either list moves.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.relayGroupList.liveRelayGroupServers.sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.relayFeedsList.flow.sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
|
||||
/** One screen's request for the full channel directory of a single relay. */
|
||||
class RelayGroupsOnRelayQueryState(
|
||||
val relay: NormalizedRelayUrl,
|
||||
val account: Account,
|
||||
)
|
||||
|
||||
private val RELAY_GROUP_DIRECTORY_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
SupportedRolesEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Subscribes to the relay-signed directory (kinds 39000-39003) of a single relay,
|
||||
* so the "browse a relay's channels" screen sees every group the relay hosts. The
|
||||
* relay signs these with its own key; a single-relay, unscoped-by-`d` query pulls
|
||||
* the whole directory. Consumed into per-group [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]s
|
||||
* keyed by (relay + group id).
|
||||
*/
|
||||
class RelayGroupsOnRelayFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<RelayGroupsOnRelayQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
RelayGroupsOnRelaySubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
|
||||
class RelayGroupsOnRelaySubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RelayGroupsOnRelayQueryState>,
|
||||
) : PerUniqueIdEoseManager<RelayGroupsOnRelayQueryState, NormalizedRelayUrl>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: RelayGroupsOnRelayQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = key.relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_DIRECTORY_KINDS,
|
||||
limit = 500,
|
||||
since = since?.get(key.relay)?.time,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
override fun id(key: RelayGroupsOnRelayQueryState) = key.relay
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
@Composable
|
||||
fun RelayGroupsOnRelaySubscription(
|
||||
relay: NormalizedRelayUrl,
|
||||
dataSource: RelayGroupsOnRelayFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account, relay) {
|
||||
RelayGroupsOnRelayQueryState(relay, accountViewModel.account)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
/** One scan of the group cache, grouped by host relay — reused across every relay in a filter set. */
|
||||
fun relayGroupChannelsByRelay(): Map<NormalizedRelayUrl, List<RelayGroupChannel>> =
|
||||
LocalCache.relayGroupChannels
|
||||
.filter { _, _ -> true }
|
||||
.groupBy { it.groupId.relayUrl }
|
||||
|
||||
/**
|
||||
* A group's kind-39000 is relay-signed, so a follow shows up in three distinct ways, and each is a
|
||||
* real relay-side REQ (not a broad "pull everything" pass):
|
||||
* 1. the follow IS the relay signing key → `{kinds:[39000], authors:<follows>}` (metadata included);
|
||||
* 2. a follow is an admin/member → `{kinds:[39001,39002], #p:<follows>}` (standard p-tag filter);
|
||||
* 3. metadata backfill for the groups (2) surfaced → `{kinds:[39000], #d:<their group-ids>}`, read
|
||||
* from the rosters already in [cachedChannels] (empty on the first pass, filled once (2)'s events
|
||||
* land and the sub-assembler re-invalidates).
|
||||
*
|
||||
* [cachedChannels] is this relay's slice of [relayGroupChannelsByRelay]; the set-level callers scan
|
||||
* the cache once and pass the slice so the (3) backfill isn't an O(relays × total-groups) re-scan.
|
||||
*/
|
||||
fun filterRelayGroupsByAuthors(
|
||||
relay: NormalizedRelayUrl,
|
||||
authors: Set<HexKey>,
|
||||
since: Long? = null,
|
||||
cachedChannels: List<RelayGroupChannel> = LocalCache.getRelayGroupChannelsOnRelay(relay),
|
||||
): List<RelayBasedFilter> {
|
||||
if (authors.isEmpty()) return emptyList()
|
||||
val authorList = authors.sorted()
|
||||
|
||||
val filters =
|
||||
mutableListOf(
|
||||
// (1) groups whose relay signing-key is a follow
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authorList,
|
||||
kinds = listOf(GroupMetadataEvent.KIND),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
// (2) groups where a follow is an admin (39001) or member (39002)
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND),
|
||||
tags = mapOf(PTag.TAG_NAME to authorList),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// (3) backfill 39000 metadata for the roster-discovered groups already known on this relay.
|
||||
val rosterGroupIds =
|
||||
cachedChannels
|
||||
.filter { channel -> channel.admins.any { it.pubKey in authors } || channel.members.any { it in authors } }
|
||||
.map { it.groupId.id }
|
||||
if (rosterGroupIds.isNotEmpty()) {
|
||||
filters +=
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(GroupMetadataEvent.KIND),
|
||||
tags = mapOf(DTag.TAG_NAME to rosterGroupIds),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
fun filterRelayGroupsByAuthors(
|
||||
authorSet: AuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
val channelsByRelay = relayGroupChannelsByRelay()
|
||||
return authorSet.set.flatMap {
|
||||
filterRelayGroupsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
cachedChannels = channelsByRelay[it.key].orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun filterRelayGroupsByMutedAuthors(
|
||||
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
val channelsByRelay = relayGroupChannelsByRelay()
|
||||
return authorSet.set.flatMap {
|
||||
filterRelayGroupsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
cachedChannels = channelsByRelay[it.key].orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
// A community selection has no group-hosting dimension of its own; it constrains only the RELAY
|
||||
// set, so the directory is pulled per relay and every group those relays host is shown.
|
||||
|
||||
fun filterRelayGroupsByAllCommunities(
|
||||
communitySet: AllCommunitiesTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (communitySet.set.isEmpty()) return emptyList()
|
||||
|
||||
return communitySet.set.flatMap {
|
||||
filterRelayGroupsDirectory(
|
||||
relay = it.key,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun filterRelayGroupsByCommunity(
|
||||
communitySet: SingleCommunityTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (communitySet.set.isEmpty()) return emptyList()
|
||||
|
||||
return communitySet.set.flatMap {
|
||||
filterRelayGroupsDirectory(
|
||||
relay = it.key,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
// The All-Follows filter is a big-OR: a group surfaces if a follow is its relay key / admin /
|
||||
// member (via [filterRelayGroupsByAuthors]) OR it carries a followed topic / geohash. The local
|
||||
// AnyOf constraint matches all three lenses, so the REQ must fetch all three — otherwise a group
|
||||
// under a followed hashtag run by non-followed people would be matched but never requested.
|
||||
fun filterRelayGroupsByFollows(
|
||||
followsSet: AllFollowsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (followsSet.set.isEmpty()) return emptyList()
|
||||
|
||||
val channelsByRelay = relayGroupChannelsByRelay()
|
||||
return followsSet.set.flatMap {
|
||||
val relay = it.key
|
||||
val relaySince = since?.get(relay)?.time ?: defaultSince
|
||||
buildList {
|
||||
it.value.authors?.takeIf { a -> a.isNotEmpty() }?.let { authors ->
|
||||
addAll(filterRelayGroupsByAuthors(relay, authors, relaySince, channelsByRelay[relay].orEmpty()))
|
||||
}
|
||||
it.value.hashtags?.takeIf { h -> h.isNotEmpty() }?.let { hashtags ->
|
||||
addAll(filterRelayGroupsByHashtag(relay, hashtags, relaySince))
|
||||
}
|
||||
it.value.geotags?.takeIf { g -> g.isNotEmpty() }?.let { geotags ->
|
||||
addAll(filterRelayGroupsByGeohashes(relay, geotags, relaySince))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
fun filterRelayGroupsByGeohashes(
|
||||
relay: NormalizedRelayUrl,
|
||||
geotags: Set<String>,
|
||||
since: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geotags.isEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(GroupMetadataEvent.KIND),
|
||||
tags = mapOf(GeoHashTag.TAG_NAME to geotags.map { it.lowercase() }.sorted()),
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterRelayGroupsByGeohashes(
|
||||
geoSet: LocationTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geoSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return geoSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.geotags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterRelayGroupsByGeohashes(
|
||||
relay = it.key,
|
||||
geotags = it.value.geotags,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
fun filterRelayGroupsByHashtag(
|
||||
relay: NormalizedRelayUrl,
|
||||
hashtags: Set<String>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(GroupMetadataEvent.KIND),
|
||||
tags = mapOf(HashtagTag.TAG_NAME to hashtags.map { it.lowercase() }),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun filterRelayGroupsByHashtag(
|
||||
hashtagSet: HashtagTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (hashtagSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return hashtagSet.set
|
||||
.mapNotNull { relayHashSet ->
|
||||
if (relayHashSet.value.hashtags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterRelayGroupsByHashtag(
|
||||
relay = relayHashSet.key,
|
||||
hashtags = relayHashSet.value.hashtags,
|
||||
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
/**
|
||||
* Browse every group hosted on a single favorite relay (the relay chip in the discovery spinner).
|
||||
* Same whole-directory pull as [filterRelayGroupsGlobal], scoped to the one relay the chip names —
|
||||
* matching the dal's [RelayTopNavPerRelayFilterSet] → `AllGroups` on that relay constraint.
|
||||
*/
|
||||
fun filterRelayGroupsByRelay(
|
||||
relays: RelayTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
filterRelayGroupsDirectory(
|
||||
relay = relays.relayUrl,
|
||||
since = since?.get(relays.relayUrl)?.time ?: defaultSince,
|
||||
)
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
/**
|
||||
* A group's kind-39000 is relay-signed, so — unlike git repositories, whose author IS a follow —
|
||||
* the people dimension (relay-key follow / follow-is-admin / follow-is-member) can't be expressed
|
||||
* as an `authors` REQ. For Global and the people filters we therefore pull the whole directory
|
||||
* (metadata + rosters) for each relay in the resolved set and let
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.RelayGroupDiscoveryFeedFilter]
|
||||
* narrow it locally. Only the topic/geo filters ([filterRelayGroupsByHashtag]/[filterRelayGroupsByGeohashes])
|
||||
* carry a real relay-side constraint.
|
||||
*/
|
||||
val RELAY_GROUP_DISCOVERY_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterRelayGroupsDirectory(
|
||||
relay: NormalizedRelayUrl,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_DISCOVERY_KINDS,
|
||||
limit = 500,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
fun filterRelayGroupsGlobal(
|
||||
relays: GlobalTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.set.isEmpty()) return emptyList()
|
||||
|
||||
// No `since` floor: a group's kind-39000 is stable metadata that a relay rarely re-signs, so a
|
||||
// "last week" cutoff (as the git-repos feed uses) would hide long-lived groups. We page on the
|
||||
// whole directory instead, carrying only the EOSE cursor once we have one.
|
||||
return relays.set.flatMap {
|
||||
filterRelayGroupsDirectory(
|
||||
relay = it.key,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
@@ -76,6 +77,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
@@ -84,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.notify
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
@@ -98,6 +101,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -543,6 +547,45 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel -> {
|
||||
// NIP-29 group message: a kind-9 chat scoped to the group with an
|
||||
// `h` tag. The event is published only to the group's host relay
|
||||
// (channel.relays()), where relay29 authorizes and routes it.
|
||||
val replyingToEvent = replyTo.value?.toEventHint<ChatEvent>()
|
||||
if (replyingToEvent != null) {
|
||||
// A reply quotes its parent via a NIP-18 `q` tag (added by ChatEvent.reply)
|
||||
// + a `p` notify to its author. The group scope (`h`) alone doesn't link a
|
||||
// reply to what it answers — without the `q` tag the quote renders in the
|
||||
// composer but is missing from the signed event.
|
||||
ChatEvent.reply(tagger.message, replyingToEvent) {
|
||||
hTag(channel.groupId.id)
|
||||
pTag(replyingToEvent.toPTag())
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
} else {
|
||||
ChatEvent.build(tagger.message) {
|
||||
hTag(channel.groupId.id)
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
|
||||
+19
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+182
-4
@@ -20,12 +20,20 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -33,6 +41,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
@@ -40,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -47,6 +57,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
@@ -70,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
@@ -80,11 +93,15 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
|
||||
@Composable
|
||||
@@ -93,11 +110,18 @@ fun ChatroomHeaderCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val isEmptyMarmotPlaceholder =
|
||||
baseNote.event == null &&
|
||||
baseNote.inGatherers?.any { it is MarmotGroupChatroom } == true
|
||||
// Some Messages rows have no event: a per-relay grouped row (RelayGroupServerRoomNote), or a
|
||||
// joined Marmot/NIP-29 group with no messages yet (an event-less placeholder carrying its channel
|
||||
// as a gatherer). Render these directly instead of waiting for an event that never arrives, which
|
||||
// would blank the row.
|
||||
val rendersWithoutEvent =
|
||||
baseNote is RelayGroupServerRoomNote ||
|
||||
(
|
||||
baseNote.event == null &&
|
||||
baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == true
|
||||
)
|
||||
|
||||
if (baseNote.event != null || isEmptyMarmotPlaceholder) {
|
||||
if (baseNote.event != null || rendersWithoutEvent) {
|
||||
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
val hasEvent by observeNoteHasEvent(baseNote, accountViewModel)
|
||||
@@ -132,12 +156,41 @@ private fun ChatroomEntry(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (lastMessage is RelayGroupServerRoomNote) {
|
||||
RelayGroupServerRoomCompose(lastMessage, accountViewModel, nav)
|
||||
return
|
||||
}
|
||||
|
||||
val marmotGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
|
||||
if (marmotGroup != null) {
|
||||
MarmotGroupRoomCompose(lastMessage, marmotGroup, accountViewModel, nav)
|
||||
return
|
||||
}
|
||||
|
||||
val relayGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }
|
||||
if (relayGroup != null) {
|
||||
RelayGroupRoomCompose(lastMessage, relayGroup, accountViewModel, nav)
|
||||
return
|
||||
}
|
||||
|
||||
// A NIP-29 group message whose channel gatherer didn't attach (e.g. loaded before its channel
|
||||
// existed, or via a path that skips attach) has no case in the when() below and would blank out.
|
||||
// Resolve the group from its `h` tag + provenance relay and render the group row anyway.
|
||||
val groupScopedEvent = lastMessage.event?.takeIf { it.isGroupScoped() }
|
||||
if (groupScopedEvent != null) {
|
||||
val gid = groupScopedEvent.groupId()
|
||||
val hostRelay = lastMessage.relays.firstOrNull()
|
||||
if (gid != null && hostRelay != null) {
|
||||
RelayGroupRoomCompose(
|
||||
lastMessage,
|
||||
LocalCache.getOrCreateRelayGroupChannel(GroupId(gid, hostRelay)),
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val baseNoteEvent = lastMessage.event
|
||||
when (baseNoteEvent) {
|
||||
// ChannelMessageEvent and ChannelMetadataEvent both expose channelId() via
|
||||
@@ -299,6 +352,131 @@ private fun MarmotGroupRoomCompose(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupRoomCompose(
|
||||
lastMessage: Note,
|
||||
baseChannel: RelayGroupChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val author = lastMessage.author
|
||||
val noteEvent = lastMessage.event
|
||||
val lastContent =
|
||||
if (author != null && noteEvent != null) {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
"$authorName: ${noteEvent.content.take(200)}"
|
||||
} else {
|
||||
// Event-less placeholder row for a just-joined group with no messages yet — say so
|
||||
// explicitly (like Marmot groups) instead of an empty second line.
|
||||
stringRes(R.string.relay_group_no_messages_yet)
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = channel.groupId.id,
|
||||
channelPicture = channel.profilePicture(),
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
RelayNameChip(
|
||||
label = channel.groupId.relayUrl.displayUrl(),
|
||||
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = false,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupServerRoomCompose(
|
||||
row: RelayGroupServerRoomNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val relay = row.relay
|
||||
val relayInfo by loadRelayInfo(relay)
|
||||
val host = relay.displayUrl()
|
||||
val name = relayInfo.name?.takeIf { it.isNotBlank() } ?: host
|
||||
|
||||
val author = row.newestMessage?.author
|
||||
val noteEvent = row.newestMessage?.event
|
||||
val lastContent =
|
||||
if (author != null && noteEvent != null) {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
"$authorName: ${noteEvent.content.take(200)}"
|
||||
} else {
|
||||
stringRes(R.string.relay_group_no_messages_yet)
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = relay.url,
|
||||
channelPicture = relayInfo.icon,
|
||||
channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.relay_group_server_label, modifier) },
|
||||
channelLastTime = row.newestMessage?.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = false,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
onClick = { nav.nav(Route.RelayGroupServer(relay.url)) },
|
||||
)
|
||||
}
|
||||
|
||||
/** A small tappable chip naming the relay a channel is hosted on. */
|
||||
@Composable
|
||||
private fun RelayNameChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(11.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelTitleWithLabelInfo(
|
||||
channelName: String,
|
||||
|
||||
+145
-4
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
|
||||
import com.vitorpamplona.amethyst.commons.util.replace
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -29,11 +31,17 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupChatContent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
|
||||
class ChatroomListKnownFeedFilter(
|
||||
val account: Account,
|
||||
@@ -89,7 +97,38 @@ class ChatroomListKnownFeedFilter(
|
||||
}
|
||||
}
|
||||
|
||||
return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups).toSet())
|
||||
// NIP-29 relay groups the user joined (kind 10009). In INLINE view mode each group is its
|
||||
// own row tagged with its host relay; in GROUPED mode all the groups on one relay collapse
|
||||
// to a single relay row positioned by that relay's newest message. Both interleave with the
|
||||
// rest of the Messages list by recency.
|
||||
val relayGroups =
|
||||
when (account.settings.relayGroupViewMode.value) {
|
||||
RelayGroupViewMode.INLINE ->
|
||||
account.relayGroupList.liveRelayGroupList.value.mapNotNull { groupTag ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(groupTag.relayUrl) ?: return@mapNotNull null
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay))
|
||||
// Newest loaded chat message, or a placeholder row so a just-joined group shows
|
||||
// up on Messages before its first kind-9 arrives (mirrors the Marmot-group path
|
||||
// above). Content kinds only — never a reaction/deletion as the "last message".
|
||||
channel.newestChatNote(account) ?: channel.placeholderNote()
|
||||
}
|
||||
|
||||
RelayGroupViewMode.GROUPED ->
|
||||
// One row per host relay (never duplicated), carrying the newest chat across ALL of
|
||||
// that relay's joined groups so it lands in the newest-message spot among the DMs.
|
||||
account.relayGroupList.liveRelayGroupList.value
|
||||
.groupBy { it.relayUrl }
|
||||
.mapNotNull { (relayUrl, tags) ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
|
||||
val newest =
|
||||
tags
|
||||
.mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) }
|
||||
.maxByOrNull { it.createdAt() ?: 0L }
|
||||
RelayGroupServerRoomNote(relay, newest)
|
||||
}
|
||||
}
|
||||
|
||||
return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups).toSet())
|
||||
}
|
||||
|
||||
override fun updateListWith(
|
||||
@@ -104,8 +143,13 @@ class ChatroomListKnownFeedFilter(
|
||||
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account)
|
||||
|
||||
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) {
|
||||
if (newRelevantPrivateMessages.isEmpty() &&
|
||||
newRelevantPublicMessages.isEmpty() &&
|
||||
newRelevantEphemeralChats.isEmpty() &&
|
||||
newRelevantRelayGroups.isEmpty()
|
||||
) {
|
||||
return oldList
|
||||
}
|
||||
|
||||
@@ -160,6 +204,21 @@ class ChatroomListKnownFeedFilter(
|
||||
}
|
||||
}
|
||||
|
||||
newRelevantRelayGroups.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
if (newNotePair.key == oldNote.relayGroupRowKey()) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) {
|
||||
myNewList = myNewList.replace(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
return sort(myNewList.toSet()).take(1000)
|
||||
}
|
||||
|
||||
@@ -170,11 +229,21 @@ class ChatroomListKnownFeedFilter(
|
||||
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account)
|
||||
|
||||
return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) {
|
||||
return if (newRelevantPrivateMessages.isEmpty() &&
|
||||
newRelevantPublicMessages.isEmpty() &&
|
||||
newRelevantEphemeralChats.isEmpty() &&
|
||||
newRelevantRelayGroups.isEmpty()
|
||||
) {
|
||||
emptySet()
|
||||
} else {
|
||||
(newRelevantPrivateMessages.values + newRelevantPublicMessages.values + newRelevantEphemeralChats.values).toSet()
|
||||
(
|
||||
newRelevantPrivateMessages.values +
|
||||
newRelevantPublicMessages.values +
|
||||
newRelevantEphemeralChats.values +
|
||||
newRelevantRelayGroups.values
|
||||
).toSet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +296,78 @@ class ChatroomListKnownFeedFilter(
|
||||
return newRelevantEphemeralChats
|
||||
}
|
||||
|
||||
/** The newest actual chat message loaded in this group's channel, or null if none yet. */
|
||||
private fun RelayGroupChannel.newestChatNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true }
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/**
|
||||
* The row a relay-group note belongs to in the feed, so [updateListWith] can find and replace it:
|
||||
* a per-relay [RelayGroupServerRoomNote] (GROUPED), a joined group's chat note keyed by group id
|
||||
* (INLINE), or an empty-group placeholder resolved back to its group id via its channel gatherer.
|
||||
*/
|
||||
private fun Note.relayGroupRowKey(): String? =
|
||||
when (this) {
|
||||
is RelayGroupServerRoomNote -> relay.url
|
||||
else ->
|
||||
event?.takeIf { it.isGroupScoped() }?.groupId()
|
||||
?: inGatherers?.firstNotNullOfOrNull { (it as? RelayGroupChannel)?.groupId?.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest relay-group rows from the new items, keyed the same way as [relayGroupRowKey]: by group
|
||||
* id in INLINE mode (one row per group) and by host relay url in GROUPED mode (one row per relay).
|
||||
* Only actual group content counts — a reaction (kind 7)/deletion/label carries the group's `h`
|
||||
* tag too, so [Event.isGroupChatContent] gates them out of ever becoming a room's "last message".
|
||||
*/
|
||||
private fun filterRelevantRelayGroupMessages(
|
||||
newItems: Set<Note>,
|
||||
account: Account,
|
||||
): MutableMap<String, Note> {
|
||||
val joined = account.relayGroupList.liveRelayGroupList.value
|
||||
if (joined.isEmpty()) return mutableMapOf()
|
||||
|
||||
return when (account.settings.relayGroupViewMode.value) {
|
||||
RelayGroupViewMode.INLINE -> {
|
||||
val joinedGroupIds = joined.mapTo(HashSet()) { it.groupId }
|
||||
val result = mutableMapOf<String, Note>()
|
||||
newItems.forEach { newNote ->
|
||||
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId()
|
||||
if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) {
|
||||
val lastNote = result[gid]
|
||||
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
|
||||
result[gid] = newNote
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
RelayGroupViewMode.GROUPED -> {
|
||||
val groupToRelay = HashMap<String, NormalizedRelayUrl>()
|
||||
joined.forEach { tag ->
|
||||
RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { groupToRelay[tag.groupId] = it }
|
||||
}
|
||||
// Newest new message per host relay, collapsed into one per-relay row.
|
||||
val newestPerRelay = HashMap<NormalizedRelayUrl, Note>()
|
||||
newItems.forEach { newNote ->
|
||||
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() ?: return@forEach
|
||||
val relay = groupToRelay[gid] ?: return@forEach
|
||||
if (!account.isAcceptable(newNote)) return@forEach
|
||||
val lastNote = newestPerRelay[relay]
|
||||
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
|
||||
newestPerRelay[relay] = newNote
|
||||
}
|
||||
}
|
||||
val result = mutableMapOf<String, Note>()
|
||||
newestPerRelay.forEach { (relay, note) -> result[relay.url] = RelayGroupServerRoomNote(relay, note) }
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterRelevantPrivateMessages(
|
||||
newItems: Set<Note>,
|
||||
account: Account,
|
||||
|
||||
+47
@@ -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}"
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -119,12 +119,13 @@ fun MessagesPager(
|
||||
tabs: List<MessagesTabItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
modifier =
|
||||
Modifier.zonedDrawerSwipe(
|
||||
modifier.zonedDrawerSwipe(
|
||||
pagerState = pagerState,
|
||||
openDrawer = nav::openDrawer,
|
||||
),
|
||||
|
||||
+15
@@ -21,11 +21,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
@@ -38,6 +40,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager
|
||||
@@ -98,11 +102,22 @@ fun MessagesSinglePane(
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
// Keep joined groups' rosters live while the messages list is on top, so
|
||||
// membership/pending state is accurate inline without opening each chat.
|
||||
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
|
||||
|
||||
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a cache hit
|
||||
// when those groups surface in discovery or any gated surface.
|
||||
WarmJoinedRelayGroupNip11(accountViewModel)
|
||||
|
||||
// The inline-vs-grouped NIP-29 display preference lives in Settings › Messages; joined groups
|
||||
// (or per-relay rows in grouped mode) are woven directly into the feed below.
|
||||
MessagesPager(
|
||||
pagerState,
|
||||
tabs,
|
||||
accountViewModel,
|
||||
nav,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -46,6 +46,8 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
|
||||
@@ -92,6 +94,14 @@ fun MessagesTwoPane(
|
||||
TwoPane(
|
||||
first = {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) {
|
||||
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
|
||||
|
||||
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a
|
||||
// cache hit when those groups surface in discovery or any gated surface.
|
||||
WarmJoinedRelayGroupNip11(accountViewModel)
|
||||
|
||||
// The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined
|
||||
// groups (or per-relay rows in grouped mode) are woven into the list itself.
|
||||
ChatroomList(
|
||||
knownFeedContentState,
|
||||
newFeedContentState,
|
||||
|
||||
+80
-5
@@ -43,8 +43,10 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -61,7 +63,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.IntentCompat
|
||||
@@ -85,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -122,6 +127,8 @@ import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
@@ -149,11 +156,14 @@ fun ShortNotePostScreen(
|
||||
forkId: HexKey? = null,
|
||||
versionId: HexKey? = null,
|
||||
draftId: HexKey? = null,
|
||||
groupThreadId: HexKey? = null,
|
||||
groupThreadRelayUrl: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
val postViewModel: ShortNotePostViewModel = viewModel()
|
||||
postViewModel.init(accountViewModel)
|
||||
postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context.getActivity()
|
||||
@@ -347,6 +357,44 @@ private fun NewPostScreenBody(
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.wantsSubject || postViewModel.groupThreadTarget != null) {
|
||||
// Styled like the "To"/"Subject" rows in the new-DM composer: an inline label
|
||||
// with a borderless field and a hairline divider, rather than a boxed input.
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = Size10dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
|
||||
Text(
|
||||
text =
|
||||
stringRes(
|
||||
if (postViewModel.groupThreadTarget != null) {
|
||||
R.string.relay_group_thread_title_label
|
||||
} else {
|
||||
R.string.messages_new_message_subject
|
||||
},
|
||||
),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500,
|
||||
)
|
||||
ThinPaddingTextField(
|
||||
state = postViewModel.subject,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.messages_new_message_subject_caption),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
// Only show text input if no voice message is being posted
|
||||
if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) {
|
||||
Row(
|
||||
@@ -749,8 +797,9 @@ private fun BottomRowActions(
|
||||
)
|
||||
|
||||
// Polls publish kinds that can't travel inside a private wrap, so the
|
||||
// two toggles are mutually exclusive.
|
||||
if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll) {
|
||||
// two toggles are mutually exclusive. Neither a private wrap nor a poll makes sense for a
|
||||
// NIP-29 group thread (it publishes plainly to the host relay), so hide both there.
|
||||
if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll && postViewModel.groupThreadTarget == null) {
|
||||
AddPrivateNoteButton(
|
||||
isActive = postViewModel.wantsPrivateNote,
|
||||
isLocked = postViewModel.privateNoteLocked,
|
||||
@@ -759,7 +808,10 @@ private fun BottomRowActions(
|
||||
}
|
||||
}
|
||||
|
||||
if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) && !postViewModel.wantsPrivateNote) {
|
||||
if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) &&
|
||||
!postViewModel.wantsPrivateNote &&
|
||||
postViewModel.groupThreadTarget == null
|
||||
) {
|
||||
AddPollButton(postViewModel.wantsPoll || postViewModel.wantsZapPoll) {
|
||||
val isActive = postViewModel.wantsPoll || postViewModel.wantsZapPoll
|
||||
if (isActive) {
|
||||
@@ -781,6 +833,13 @@ private fun BottomRowActions(
|
||||
}
|
||||
}
|
||||
|
||||
// A group thread's title is required, so the field is always shown for it — no toggle.
|
||||
if (postViewModel.groupThreadTarget == null) {
|
||||
AddSubjectButton(postViewModel.wantsSubject) {
|
||||
postViewModel.toggleSubject()
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) {
|
||||
postViewModel.toggleMarkAsSensitive()
|
||||
}
|
||||
@@ -790,8 +849,9 @@ private fun BottomRowActions(
|
||||
}
|
||||
|
||||
// Private wraps are built and sent immediately; scheduling them would
|
||||
// require wrapping at publish time, so the option is hidden for now.
|
||||
if (!postViewModel.wantsPrivateNote) {
|
||||
// require wrapping at publish time, so the option is hidden for now. Scheduling also
|
||||
// bypasses the host-relay pin, so it's hidden for NIP-29 group threads too.
|
||||
if (!postViewModel.wantsPrivateNote && postViewModel.groupThreadTarget == null) {
|
||||
ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
|
||||
}
|
||||
|
||||
@@ -849,6 +909,21 @@ private fun AddPrivateNoteButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddSubjectButton(
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Topic,
|
||||
contentDescription = stringRes(R.string.messages_new_message_subject),
|
||||
modifier = Modifier.height(22.dp),
|
||||
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPollButton(
|
||||
isPollActive: Boolean,
|
||||
|
||||
+80
-1
@@ -94,7 +94,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
@@ -113,10 +116,12 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.markedETags
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.notify
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.notify
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
@@ -133,6 +138,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
@@ -305,6 +311,11 @@ open class ShortNotePostViewModel :
|
||||
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override val forwardZapToEditting = TextFieldState()
|
||||
|
||||
// Optional subject line: a NIP-14 `subject` tag on a kind-1 note, or the `title` of a NIP-29
|
||||
// kind-11 group thread. Toggled from the bottom row; auto-on for group threads.
|
||||
var wantsSubject by mutableStateOf(false)
|
||||
val subject = TextFieldState()
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
var contentWarningDescription by mutableStateOf("")
|
||||
@@ -479,6 +490,30 @@ open class ShortNotePostViewModel :
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
}
|
||||
|
||||
/**
|
||||
* When set, this composer produces a NIP-29 kind-11 group thread instead of a kind-1 note:
|
||||
* the title is the first line, the rest is the body, and it publishes ONLY to the group's host
|
||||
* [relays] (relay29 authorizes writes by the `h` tag). Toggles that don't apply to a group thread
|
||||
* (poll, private note, scheduling) are hidden while this is set.
|
||||
*/
|
||||
class GroupThreadTarget(
|
||||
val groupId: HexKey,
|
||||
val relays: List<NormalizedRelayUrl>,
|
||||
)
|
||||
|
||||
var groupThreadTarget: GroupThreadTarget? = null
|
||||
private set
|
||||
|
||||
fun setGroupThread(
|
||||
groupId: HexKey?,
|
||||
relayUrl: String?,
|
||||
) {
|
||||
val relay = relayUrl?.let { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
groupThreadTarget = if (groupId != null && relay != null) GroupThreadTarget(groupId, listOf(relay)) else null
|
||||
// A thread wants a title, so surface the field by default when composing one.
|
||||
if (groupThreadTarget != null) wantsSubject = true
|
||||
}
|
||||
|
||||
open fun load(
|
||||
replyingTo: Note?,
|
||||
quote: Note?,
|
||||
@@ -920,8 +955,19 @@ open class ShortNotePostViewModel :
|
||||
val anonymous = wantsAnonymousPost
|
||||
val scheduledFor = scheduledForSec
|
||||
val privately = wantsPrivateNote
|
||||
val threadTarget = groupThreadTarget
|
||||
cancel()
|
||||
|
||||
if (threadTarget != null) {
|
||||
// NIP-29 group thread: publish only to the group's host relay, never the account's
|
||||
// outbox — bypass the private/scheduled/anonymous paths entirely.
|
||||
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { threadTarget.relays }
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (privately && template.kind == TextNoteEvent.KIND) {
|
||||
// Gift-wrap to the p-tagged users instead of publishing. Private
|
||||
// wins over the anonymous and scheduled modes: a locked private
|
||||
@@ -1073,8 +1119,28 @@ open class ShortNotePostViewModel :
|
||||
|
||||
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
|
||||
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
|
||||
val subjectValue = subject.text.toString().trim()
|
||||
|
||||
return if (wantsPoll) {
|
||||
val threadTarget = groupThreadTarget
|
||||
return if (threadTarget != null) {
|
||||
// NIP-29 kind-11 group thread. The title is the required subject field (no first-line
|
||||
// fallback); the body is the full message. Scoped to the group by `h`; the host relay
|
||||
// authorizes the write.
|
||||
ThreadEvent.build(tagger.message.trim(), subjectValue) {
|
||||
hTag(threadTarget.groupId)
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
geoHash?.let { geohash(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
} else if (wantsPoll) {
|
||||
val options = pollOptions.map { it.value }
|
||||
|
||||
if (options.isEmpty()) return null
|
||||
@@ -1200,6 +1266,8 @@ open class ShortNotePostViewModel :
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
if (wantsSubject && subjectValue.isNotBlank()) subject(subjectValue)
|
||||
|
||||
geoHash?.let { geohash(it) }
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
@@ -1373,6 +1441,8 @@ open class ShortNotePostViewModel :
|
||||
zapRaiserAmount.value = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsSubject = false
|
||||
subject.clearText()
|
||||
wantsToMarkAsSensitive = false
|
||||
contentWarningDescription = ""
|
||||
wantsToAddGeoHash = false
|
||||
@@ -1502,6 +1572,9 @@ open class ShortNotePostViewModel :
|
||||
return !isUploadingVoice && !mediaUploadTracker.isUploading && processingPreset == null
|
||||
}
|
||||
|
||||
// A NIP-29 group thread requires a title — it has no first-line fallback.
|
||||
if (groupThreadTarget != null && subject.text.isBlank()) return false
|
||||
|
||||
// Regular text/media posts require text
|
||||
return message.text.toString().isNotBlank() &&
|
||||
!mediaUploadTracker.isUploading &&
|
||||
@@ -1760,6 +1833,12 @@ open class ShortNotePostViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun toggleSubject() {
|
||||
wantsSubject = !wantsSubject
|
||||
if (!wantsSubject) subject.clearText()
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun toggleExpirationDate() {
|
||||
wantsExpirationDate = !wantsExpirationDate
|
||||
if (wantsExpirationDate) {
|
||||
|
||||
+131
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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),
|
||||
|
||||
@@ -1929,6 +1929,110 @@
|
||||
<string name="messages_create_public_chat">Public</string>
|
||||
<string name="messages_create_group">Group</string>
|
||||
<string name="messages_create_public_private_chat_description">New Public or Private Group</string>
|
||||
<string name="relay_groups_title">Relay Groups</string>
|
||||
<string name="relay_group_view_inline">Inline</string>
|
||||
<string name="relay_group_view_grouped">By relay</string>
|
||||
<string name="relay_group_view_inline_desc">Show each group as its own conversation, mixed in with your chats.</string>
|
||||
<string name="relay_group_view_grouped_desc">Collapse each relay\'s groups into a single row, placed at its newest message.</string>
|
||||
<string name="relay_group_view_mode_title">NIP-29 group display</string>
|
||||
<string name="relay_group_server_label">Relay groups</string>
|
||||
<string name="messages_settings">Messages</string>
|
||||
<string name="messages_settings_search_keywords" translatable="false">messages, chats, groups, nip-29, relay, inline, dm</string>
|
||||
<string name="relay_group_create_title">Create a group</string>
|
||||
<string name="relay_group_relay_no_nip29">This relay doesn\'t advertise support for NIP-29 relay groups. A group created here won\'t work — its name, members and messages won\'t be managed by the relay. Pick a relay that supports relay-based groups.</string>
|
||||
<string name="relay_group_create_name">Group name</string>
|
||||
<string name="relay_group_create_topic">Topic (optional)</string>
|
||||
<string name="relay_group_create_private">Private (members-only read)</string>
|
||||
<string name="relay_group_create_invite_only">Invite only</string>
|
||||
<string name="relay_group_create_confirm">Create</string>
|
||||
<string name="relay_group_invite_title">Invite people</string>
|
||||
<string name="relay_group_invite_description">Share this code so people can join this group.</string>
|
||||
<string name="relay_group_invite_generating">Creating an invite…</string>
|
||||
<string name="relay_group_invite_copied">Invite code copied</string>
|
||||
<string name="relay_group_invite_code_label">Invite code (for this invite-only group):</string>
|
||||
<string name="relay_group_join_title">Join group</string>
|
||||
<string name="relay_group_join_code_label">Invite code</string>
|
||||
<string name="relay_group_join_code_hint">This group is invite-only. Enter the code you were given.</string>
|
||||
<string name="relay_group_join_confirm">Join</string>
|
||||
<string name="relay_group_pending">Requested</string>
|
||||
<string name="relay_group_role_admin">Admin</string>
|
||||
<string name="relay_group_role_moderator">Moderator</string>
|
||||
<string name="relay_group_role_member">Member</string>
|
||||
<string name="relay_group_members_title">Members</string>
|
||||
<string name="relay_group_make_admin">Make admin</string>
|
||||
<string name="relay_group_make_moderator">Make moderator</string>
|
||||
<string name="relay_group_demote_member">Remove role</string>
|
||||
<string name="relay_group_remove_user">Remove from group</string>
|
||||
<string name="relay_group_remove_user_confirm">Remove %1$s from this group? They will lose access until re-added or re-invited.</string>
|
||||
<string name="relay_group_edit_title">Edit group</string>
|
||||
<string name="relay_group_edit_name">Group name</string>
|
||||
<string name="relay_group_edit_topic">Topic (optional)</string>
|
||||
<string name="relay_group_edit_private">Private (members-only read)</string>
|
||||
<string name="relay_group_edit_invite_only">Invite only</string>
|
||||
<string name="relay_group_edit_confirm">Save</string>
|
||||
<string name="relay_group_menu_members">Members</string>
|
||||
<string name="relay_group_menu_edit">Edit group</string>
|
||||
<string name="relay_group_threads_title">Threads</string>
|
||||
<string name="relay_group_open">Open group</string>
|
||||
<string name="relay_group_channels_empty">No groups on this relay yet.</string>
|
||||
<string name="relay_group_invite_preparing">Preparing invite…</string>
|
||||
<string name="relay_group_badge_private">Private</string>
|
||||
<string name="relay_group_badge_invite_only">Invite-only</string>
|
||||
<string name="relay_group_browse_invalid_url">Enter a valid relay URL (wss://…).</string>
|
||||
<string name="relay_group_field_name">Name</string>
|
||||
<string name="relay_group_field_about">Description</string>
|
||||
<string name="relay_group_field_picture">Group picture</string>
|
||||
<string name="relay_group_add_photo">Add photo</string>
|
||||
<string name="relay_group_change_photo">Change photo</string>
|
||||
<string name="relay_group_section_discovery">Discovery</string>
|
||||
<string name="relay_group_section_discovery_desc">Help people find this group. Topics and a location surface it under the matching discovery filters (if the host relay supports them).</string>
|
||||
<string name="relay_group_field_topics">Topics</string>
|
||||
<string name="relay_group_field_topics_hint">bitcoin, nostr, art</string>
|
||||
<string name="relay_group_field_geohash">Location (geohash)</string>
|
||||
<string name="relay_group_field_geohash_hint">u0nd</string>
|
||||
<string name="relay_group_section_permissions">Permissions</string>
|
||||
<string name="relay_group_flag_private">Private</string>
|
||||
<string name="relay_group_flag_private_desc">Only members can read messages.</string>
|
||||
<string name="relay_group_flag_invite_only">Invite-only</string>
|
||||
<string name="relay_group_flag_invite_only_desc">People must be invited or approved to join.</string>
|
||||
<string name="relay_group_flag_restricted">Members-only posting</string>
|
||||
<string name="relay_group_flag_restricted_desc">Only members can post messages.</string>
|
||||
<string name="relay_group_flag_hidden">Unlisted</string>
|
||||
<string name="relay_group_flag_hidden_desc">Hide this group from the relay\'s public directory.</string>
|
||||
<string name="relay_group_discovery_empty">Looking for groups across your relays…</string>
|
||||
<string name="relay_group_discovery_empty_filtered">No groups found for this filter yet.</string>
|
||||
<string name="relay_group_favorite_relay">Favorite this relay</string>
|
||||
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
|
||||
<string name="relay_group_thread_new">New thread</string>
|
||||
<string name="relay_group_thread_untitled">Untitled</string>
|
||||
<string name="relay_group_thread_title_label">Title</string>
|
||||
<string name="relay_group_thread_body_label">Write something…</string>
|
||||
<string name="relay_group_thread_post">Post</string>
|
||||
<string name="relay_group_browse_title">Find groups</string>
|
||||
<string name="relay_group_browse_description">Browse the groups hosted on a relay. Paste a relay address, or pick one below.</string>
|
||||
<string name="relay_group_browse_relay_label">Relay address</string>
|
||||
<string name="relay_group_browse_go">Browse</string>
|
||||
<string name="relay_group_browse_your_relays">Relays you\'re on</string>
|
||||
<string name="relay_group_browse_popular">Popular relays</string>
|
||||
<string name="relay_group_no_messages_yet">No messages yet</string>
|
||||
<string name="relay_group_join_to_post">Join this group to send messages.</string>
|
||||
<string name="relay_group_invite_only_to_post">This group is invite-only — you need an invite to post.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_message_count">
|
||||
<item quantity="one">%1$d message</item>
|
||||
<item quantity="other">%1$d messages</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_message_count_capped">
|
||||
<item quantity="one">%1$d+ message</item>
|
||||
<item quantity="other">%1$d+ messages</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_follows_participating">
|
||||
<item quantity="one">%1$d person you follow</item>
|
||||
<item quantity="other">%1$d people you follow</item>
|
||||
</plurals>
|
||||
<string name="messages_new_message">Private</string>
|
||||
<string name="messages_new_message_to">To</string>
|
||||
<string name="messages_new_message_subject">Subject</string>
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The per-type discovery match for relay-signed NIP-29 groups. Verifies the three people
|
||||
* paths ([relay-key follow / follow-is-admin / follow-is-member]) and the topic/geo tag
|
||||
* matches, plus that [toGroupConstraints] resolves each top-nav filter to the right shape.
|
||||
*/
|
||||
class RelayGroupDiscoveryConstraintTest {
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example/")!!
|
||||
private val relayKey = "aa".repeat(32)
|
||||
private val alice = "bb".repeat(32)
|
||||
private val bob = "cc".repeat(32)
|
||||
private val carol = "dd".repeat(32)
|
||||
private val sig = "22".repeat(64)
|
||||
|
||||
private fun channel(
|
||||
metaTags: Array<Array<String>>,
|
||||
adminPubkeys: List<String> = emptyList(),
|
||||
memberPubkeys: List<String> = emptyList(),
|
||||
): RelayGroupChannel {
|
||||
val gid = "group1"
|
||||
val ch = RelayGroupChannel(GroupId(gid, relay))
|
||||
ch.updateGroupInfo(
|
||||
GroupMetadataEvent("00".repeat(32), relayKey, 1_000L, arrayOf(arrayOf("d", gid), *metaTags), "", sig),
|
||||
)
|
||||
if (adminPubkeys.isNotEmpty()) {
|
||||
ch.updateAdmins(
|
||||
GroupAdminsEvent(
|
||||
"11".repeat(32),
|
||||
relayKey,
|
||||
1_000L,
|
||||
arrayOf(arrayOf("d", gid), *adminPubkeys.map { arrayOf("p", it, "admin") }.toTypedArray()),
|
||||
"",
|
||||
sig,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (memberPubkeys.isNotEmpty()) {
|
||||
ch.updateMembers(
|
||||
GroupMembersEvent(
|
||||
"22".repeat(32),
|
||||
relayKey,
|
||||
1_000L,
|
||||
arrayOf(arrayOf("d", gid), *memberPubkeys.map { arrayOf("p", it) }.toTypedArray()),
|
||||
"",
|
||||
sig,
|
||||
),
|
||||
)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allGroupsMatchesAnyLoadedGroup() {
|
||||
assertTrue(GroupDiscoveryConstraint.AllGroups.matches(channel(arrayOf(arrayOf("name", "Any")))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byPeopleMatchesFollowedRelayKey() {
|
||||
val c = GroupDiscoveryConstraint.ByPeople(setOf(relayKey))
|
||||
assertTrue(c.matches(channel(arrayOf(arrayOf("name", "G")))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byPeopleMatchesFollowedAdminAndMember() {
|
||||
val ch = channel(arrayOf(arrayOf("name", "G")), adminPubkeys = listOf(alice), memberPubkeys = listOf(bob))
|
||||
assertTrue(GroupDiscoveryConstraint.ByPeople(setOf(alice)).matches(ch))
|
||||
assertTrue(GroupDiscoveryConstraint.ByPeople(setOf(bob)).matches(ch))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byPeopleRejectsStrangers() {
|
||||
val ch = channel(arrayOf(arrayOf("name", "G")), adminPubkeys = listOf(alice), memberPubkeys = listOf(bob))
|
||||
assertFalse(GroupDiscoveryConstraint.ByPeople(setOf(carol)).matches(ch))
|
||||
assertFalse(GroupDiscoveryConstraint.ByPeople(emptySet()).matches(ch))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byHashtagsMatchesCaseInsensitively() {
|
||||
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("t", "Bitcoin")))
|
||||
assertTrue(GroupDiscoveryConstraint.ByHashtags(setOf("bitcoin")).matches(ch))
|
||||
assertFalse(GroupDiscoveryConstraint.ByHashtags(setOf("nostr")).matches(ch))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byGeohashesMatchesMipMapPrefix() {
|
||||
// GroupMetadataEvent.build mip-maps geohashes; here we store the full one and a prefix.
|
||||
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("g", "u0nd"), arrayOf("g", "u0")))
|
||||
assertTrue(GroupDiscoveryConstraint.ByGeohashes(setOf("u0")).matches(ch))
|
||||
assertFalse(GroupDiscoveryConstraint.ByGeohashes(setOf("9q")).matches(ch))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anyOfMatchesIfEitherLensMatches() {
|
||||
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("t", "bitcoin")))
|
||||
val c =
|
||||
GroupDiscoveryConstraint.AnyOf(
|
||||
listOf(
|
||||
GroupDiscoveryConstraint.ByPeople(setOf(carol)),
|
||||
GroupDiscoveryConstraint.ByHashtags(setOf("bitcoin")),
|
||||
),
|
||||
)
|
||||
assertTrue(c.matches(ch))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun globalFilterResolvesToAllGroupsPerRelay() {
|
||||
val set = GlobalTopNavPerRelayFilterSet(mapOf(relay to GlobalTopNavPerRelayFilter))
|
||||
val constraints = set.toGroupConstraints()
|
||||
assertEquals(GroupDiscoveryConstraint.AllGroups, constraints[relay])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorsFilterResolvesToByPeople() {
|
||||
val set = AuthorsTopNavPerRelayFilterSet(mapOf(relay to AuthorsTopNavPerRelayFilter(setOf(alice))))
|
||||
val constraints = set.toGroupConstraints()
|
||||
assertEquals(GroupDiscoveryConstraint.ByPeople(setOf(alice)), constraints[relay])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hashtagFilterResolvesToByHashtags() {
|
||||
val set = HashtagTopNavPerRelayFilterSet(mapOf(relay to HashtagTopNavPerRelayFilter(setOf("bitcoin"))))
|
||||
val constraints = set.toGroupConstraints()
|
||||
assertTrue(constraints[relay] is GroupDiscoveryConstraint.ByHashtags)
|
||||
}
|
||||
}
|
||||
@@ -449,6 +449,27 @@ the cards. For a third-party observer, `graperank operator providers` prints the
|
||||
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
|
||||
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
|
||||
|
||||
### Relay groups (NIP-29)
|
||||
|
||||
Relay-based groups (à la Armada / relay29). A group lives on one host relay,
|
||||
addressed by `(relay, group id)`; every read and write is pinned there. Distinct
|
||||
from Marmot/MLS above — these are the NIP-29 groups Amethyst's "Relay Groups"
|
||||
screen speaks.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy relaygroup list` | Your joined groups, from your kind:10009 list (public + private). |
|
||||
| `amy relaygroup browse RELAY` | Every group a relay hosts (its 39000-39003 directory). |
|
||||
| `amy relaygroup info RELAY GID` | A group's metadata + admin/member roster. |
|
||||
| `amy relaygroup create RELAY --name X [--about A] [--private] [--closed]` | Create a group (publishes 9007 + 9002); prints the new `group_id`. |
|
||||
| `amy relaygroup join RELAY GID [--code CODE]` | Request to join (9021) and add it to your kind:10009 list. |
|
||||
| `amy relaygroup leave RELAY GID` | Leave (9022) and drop it from your kind:10009 list. |
|
||||
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
|
||||
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads current visibility and changes only the axis you pass, so re-asserting one flag never resets the other. |
|
||||
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
|
||||
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
|
||||
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
||||
|
||||
### CLINK Offers
|
||||
|
||||
| Command | What it does |
|
||||
|
||||
+4
-4
@@ -123,10 +123,10 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage:
|
||||
- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`),
|
||||
`git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs
|
||||
nak's local hints DB).
|
||||
- **Missing (7):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
|
||||
`spell` (MuSig2/FROST), `validate` (event-schema validation), and
|
||||
`group`/`nip29` (NIP-29 — amy ships MLS/Marmot instead, an intentional
|
||||
divergence rather than a gap).
|
||||
- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
|
||||
`spell` (MuSig2/FROST), and `validate` (event-schema validation).
|
||||
`relaygroup` (NIP-29) now ships alongside MLS/Marmot — the two group models
|
||||
are offered side by side rather than one substituting for the other.
|
||||
|
||||
**Design differences (not gaps):** amy is a *stateful client* (accounts,
|
||||
`~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless*
|
||||
|
||||
@@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDe
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -59,6 +60,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
@@ -75,14 +77,17 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachability
|
||||
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
|
||||
import com.vitorpamplona.quartz.utils.SeenIds
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -242,6 +247,26 @@ class Context(
|
||||
.OkHttpNip05Fetcher { _ -> okhttp },
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches [relay]'s NIP-11 relay-information document (over the same OkHttp instance), or null if
|
||||
* it serves none / the request fails. Used to read the relay's `self` pubkey — NIP-29's authority
|
||||
* for group metadata — so `relaygroup` reads can verify a 39000 was actually signed by the relay.
|
||||
*/
|
||||
suspend fun relayInfo(relay: NormalizedRelayUrl): Nip11RelayInformation? =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(relay.toHttp())
|
||||
.header("Accept", "application/nostr+json")
|
||||
.build()
|
||||
okhttp.newCall(request).execute().use { resp ->
|
||||
resp.body?.string()?.let { Nip11RelayInformation.fromJson(it) }
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
// Lazy so an anonymous read (no account dir) never materialises the
|
||||
// per-account marmot stores — constructing them would `mkdir` group dirs
|
||||
// under the shared root. Real accounts build them on first marmot use.
|
||||
|
||||
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
|
||||
@@ -257,6 +258,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"whoami" -> InitCommands.whoami(dataDir)
|
||||
"relay" -> RelayCommands.dispatch(dataDir, tail)
|
||||
"marmot" -> marmotDispatch(dataDir, tail)
|
||||
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
|
||||
"dm" -> DmCommands.dispatch(dataDir, tail)
|
||||
"profile" -> ProfileCommands.dispatch(dataDir, tail)
|
||||
"notes" -> NotesCommands.dispatch(dataDir, tail)
|
||||
@@ -671,6 +673,23 @@ private fun printUsage() {
|
||||
| dm await --peer NPUB --match TEXT wait for a matching DM
|
||||
| [--timeout SECS] (default 30s, exit 124 on timeout)
|
||||
|
|
||||
|Relay groups (NIP-29):
|
||||
| relaygroup list joined groups (from kind:10009)
|
||||
| relaygroup browse RELAY every group a relay hosts
|
||||
| relaygroup info RELAY GID a group's metadata + roster
|
||||
| relaygroup create RELAY --name NAME create a group (publishes 9007+9002)
|
||||
| [--about A] [--private] [--closed]
|
||||
| relaygroup join RELAY GID [--code CODE] request to join (kind 9021)
|
||||
| relaygroup leave RELAY GID leave (kind 9022)
|
||||
| relaygroup message RELAY GID TEXT post a kind-9 chat to the group
|
||||
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin);
|
||||
| [--about A] [--private|--public] reads current visibility and only
|
||||
| [--closed|--open] changes the axis you specify
|
||||
| relaygroup invite RELAY GID --code CODE mint an invite code (kind 9009)
|
||||
| relaygroup put-user RELAY GID PUBKEY add/promote a user (kind 9000)
|
||||
| [--role admin|moderator]
|
||||
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
||||
|
|
||||
|Marmot (MLS group messaging):
|
||||
| marmot key-package publish publish a fresh KeyPackage
|
||||
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* `amy relaygroup …` — NIP-29 relay-based groups. Every group lives on exactly
|
||||
* one host relay and is addressed by (relay, group id); all reads and writes are
|
||||
* pinned to that relay. This object owns the lifecycle verbs (create/join/leave/
|
||||
* message); reads live in [RelayGroupReadCommands], moderation in
|
||||
* [RelayGroupModerationCommands].
|
||||
*/
|
||||
object RelayGroupCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"relaygroup",
|
||||
tail,
|
||||
"relaygroup <list|browse|info|create|join|leave|message|edit|invite|put-user|remove-user> …",
|
||||
mapOf(
|
||||
"list" to { rest -> RelayGroupReadCommands.list(dataDir, rest) },
|
||||
"browse" to { rest -> RelayGroupReadCommands.browse(dataDir, rest) },
|
||||
"info" to { rest -> RelayGroupReadCommands.info(dataDir, rest) },
|
||||
"create" to { rest -> create(dataDir, rest) },
|
||||
"join" to { rest -> join(dataDir, rest) },
|
||||
"leave" to { rest -> leave(dataDir, rest) },
|
||||
"message" to { rest -> message(dataDir, rest) },
|
||||
"edit" to { rest -> RelayGroupModerationCommands.edit(dataDir, rest) },
|
||||
"invite" to { rest -> RelayGroupModerationCommands.invite(dataDir, rest) },
|
||||
"put-user" to { rest -> RelayGroupModerationCommands.putUser(dataDir, rest) },
|
||||
"remove-user" to { rest -> RelayGroupModerationCommands.removeUser(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
/** `relaygroup create RELAY --name NAME [--about A] [--private] [--closed]` → publishes 9007 + 9002. */
|
||||
private suspend fun create(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup create RELAY --name NAME [--about A] [--private] [--closed]")
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val name = args.flag("name") ?: return Output.error("bad_args", "relaygroup create requires --name")
|
||||
val about = args.flag("about")
|
||||
val isPrivate = args.bool("private")
|
||||
val isClosed = args.bool("closed")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val groupId = RandomInstance.bytes(8).toHexKey()
|
||||
val target = setOf(relay)
|
||||
|
||||
val createAck = ctx.publish(ctx.signer.sign(CreateGroupEvent.build(groupId)), target)
|
||||
val status = groupStatus(isPrivate, isClosed)
|
||||
val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status)
|
||||
val editAck = ctx.publish(ctx.signer.sign(edit), target)
|
||||
// Track it in our own kind:10009 so `relaygroup list` shows it, matching
|
||||
// the Android create flow (Account.createRelayGroup → follow).
|
||||
val listed = updateGroupList(ctx, relay, groupId, add = true)
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"name" to name,
|
||||
"private" to isPrivate,
|
||||
"closed" to isClosed,
|
||||
"published" to (createAck.values.any { it } && editAck.values.any { it }),
|
||||
"listed" to listed,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `relaygroup join RELAY GROUP_ID [--code CODE] [--reason R]` — publishes the
|
||||
* 9021 join request to the host relay AND adds the group to the caller's
|
||||
* kind-10009 list (private item), so `relaygroup list` reflects it.
|
||||
*/
|
||||
private suspend fun join(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup join RELAY GROUP_ID [--code CODE]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val join = JoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = args.flag("code"))
|
||||
val ack = ctx.publish(ctx.signer.sign(join), setOf(relay))
|
||||
val listed = updateGroupList(ctx, relay, groupId, add = true)
|
||||
Output.emit(
|
||||
mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it }, "listed" to listed),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `relaygroup leave RELAY GROUP_ID` — publishes the 9022 leave request to the
|
||||
* host relay AND removes the group from the caller's kind-10009 list.
|
||||
*/
|
||||
private suspend fun leave(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup leave RELAY GROUP_ID"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val ack = ctx.publish(ctx.signer.sign(LeaveRequestEvent.build(groupId)), setOf(relay))
|
||||
val listed = updateGroupList(ctx, relay, groupId, add = false)
|
||||
Output.emit(
|
||||
mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it }, "listed" to listed),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup message RELAY GROUP_ID <text>` → publishes a kind-9 chat with an `h` tag. */
|
||||
private suspend fun message(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val text = args.positionalOrNull(2) ?: return Output.error("bad_args", "relaygroup message RELAY GROUP_ID <text>")
|
||||
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
|
||||
return publishScoped(dataDir, rest, "relaygroup message RELAY GROUP_ID <text>") { _, groupId, _ ->
|
||||
ChatEvent.build(text) { hTag(groupId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a user-supplied relay URL for a group, or null if unparseable. */
|
||||
internal fun normalizeGroupRelay(url: String): NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(url.trim())
|
||||
|
||||
/**
|
||||
* Add or remove a group from the caller's kind-10009 "simple groups" list (private
|
||||
* item) and publish the new version to their outbox relays. Mirrors the Android
|
||||
* follow/unfollow. Returns true when the updated list was published to ≥1 relay.
|
||||
*/
|
||||
private suspend fun updateGroupList(
|
||||
ctx: Context,
|
||||
relay: NormalizedRelayUrl,
|
||||
groupId: String,
|
||||
add: Boolean,
|
||||
): Boolean {
|
||||
val outbox = ctx.outboxRelays()
|
||||
if (outbox.isEmpty()) return false
|
||||
|
||||
// Load the current list from BOTH the local store (amy's source of truth —
|
||||
// every list we've published/synced is here) and a fresh relay drain, then
|
||||
// take the newest. Relying on the drain alone is unsafe: a slow or empty
|
||||
// fetch would look like "no list", and the `create` branch below would then
|
||||
// replace the user's entire kind:10009 with just this one group.
|
||||
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
|
||||
val stored = ctx.latestReplaceable(ctx.identity.pubKeyHex, SimpleGroupListEvent.KIND) as? SimpleGroupListEvent
|
||||
val drained =
|
||||
ctx
|
||||
.drain(outbox.associateWith { listOf(filter) }, 5_000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<SimpleGroupListEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
val current = listOfNotNull(stored, drained).maxByOrNull { it.createdAt }
|
||||
|
||||
val tag = GroupTag(groupId, relay.url, null)
|
||||
// Public tags, matching the app and the reference NIP-29 clients (membership is
|
||||
// already public via the relay's kind-39002 list); reads still merge legacy
|
||||
// private items.
|
||||
val updated =
|
||||
when {
|
||||
add && current == null -> SimpleGroupListEvent.create(publicGroups = listOf(tag), signer = ctx.signer)
|
||||
add -> SimpleGroupListEvent.add(current!!, tag, isPrivate = false, signer = ctx.signer)
|
||||
current == null -> return false // nothing to remove from
|
||||
else -> SimpleGroupListEvent.remove(current, tag, signer = ctx.signer)
|
||||
}
|
||||
|
||||
return ctx.publish(updated, outbox).values.any { it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The NIP-29 status flag set for the given visibility. NIP-29 flags are
|
||||
* presence-only: a group is public/open by the ABSENCE of the private/closed
|
||||
* tags, so we emit only the restrictive flags that are actually on — never a
|
||||
* `["public"]`/`["open"]` tag (which are non-canonical and can confuse relays).
|
||||
*/
|
||||
internal fun groupStatus(
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
): Set<GroupMetadataEvent.GroupStatus> =
|
||||
buildSet {
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared skeleton for the group-scoped write verbs: parse RELAY + GROUP_ID from
|
||||
* positionals 0/1, build a template pinned to that group, sign, publish to the
|
||||
* host relay, and emit the ack. The [build] lambda returns the event template.
|
||||
*/
|
||||
internal suspend fun publishScoped(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
usage: String,
|
||||
build: (relay: NormalizedRelayUrl, groupId: String, args: Args) -> EventTemplate<out Event>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val signed = ctx.signer.sign(build(relay, groupId, args))
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
|
||||
/**
|
||||
* Moderator/admin write verbs for a relay group (the relay is the final authority
|
||||
* and rejects a request from someone lacking the role). All pinned to the group's
|
||||
* host relay via [publishScoped] or a direct publish.
|
||||
*/
|
||||
object RelayGroupModerationCommands {
|
||||
/**
|
||||
* `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]` → 9002.
|
||||
*
|
||||
* A kind-9002 edit re-asserts the group's status flags, so sending only one
|
||||
* axis would silently reset the other (e.g. `--closed` on a private group
|
||||
* would drop `private` and leak it public). To avoid that we read the group's
|
||||
* current 39000 metadata and merge: each axis keeps its current value unless
|
||||
* the caller explicitly changes it with the flag or its counter-flag.
|
||||
*/
|
||||
suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
val filter =
|
||||
Filter(kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1)
|
||||
val meta =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), 6_000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<GroupMetadataEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
if (meta == null) {
|
||||
System.err.println(
|
||||
"warning: could not read current metadata for $groupId on ${relay.url}; " +
|
||||
"visibility will be set from the flags given only",
|
||||
)
|
||||
}
|
||||
|
||||
val isPrivate =
|
||||
if (args.bool("private")) {
|
||||
true
|
||||
} else if (args.bool("public")) {
|
||||
false
|
||||
} else {
|
||||
(meta?.isPrivate() ?: false)
|
||||
}
|
||||
val isClosed =
|
||||
if (args.bool("closed")) {
|
||||
true
|
||||
} else if (args.bool("open")) {
|
||||
false
|
||||
} else {
|
||||
(meta?.isClosed() ?: false)
|
||||
}
|
||||
|
||||
// A kind-9002 re-asserts the whole metadata, so omitted fields must be carried over
|
||||
// from the current 39000 — otherwise editing only a flag would wipe name/about/tags.
|
||||
val signed =
|
||||
ctx.signer.sign(
|
||||
EditMetadataEvent.build(
|
||||
groupId,
|
||||
name = args.flag("name") ?: meta?.name(),
|
||||
about = args.flag("about") ?: meta?.about(),
|
||||
status = groupStatus(isPrivate, isClosed),
|
||||
hashtags = meta?.hashtags() ?: emptyList(),
|
||||
geohashes = meta?.geohashes()?.maxByOrNull { it.length }?.let { listOf(it) } ?: emptyList(),
|
||||
),
|
||||
)
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"private" to isPrivate,
|
||||
"closed" to isClosed,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */
|
||||
suspend fun invite(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val code = Args(rest).flag("code") ?: return Output.error("bad_args", "relaygroup invite RELAY GROUP_ID --code CODE")
|
||||
return publishScoped(dataDir, rest, "relaygroup invite RELAY GROUP_ID --code CODE") { _, groupId, _ ->
|
||||
CreateInviteEvent.build(groupId, code)
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]` → 9000. */
|
||||
suspend fun putUser(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val roles =
|
||||
args
|
||||
.flag("role")
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() } ?: emptyList()
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubkey = ctx.requireUserHex(user)
|
||||
val signed = ctx.signer.sign(PutUserEvent.build(groupId, listOf(pubkey to roles)))
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"pubkey" to pubkey,
|
||||
"roles" to roles,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup remove-user RELAY GROUP_ID PUBKEY` → 9001. */
|
||||
suspend fun removeUser(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup remove-user RELAY GROUP_ID PUBKEY"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubkey = ctx.requireUserHex(user)
|
||||
val signed = ctx.signer.sign(RemoveUserEvent.build(groupId, listOf(pubkey)))
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"pubkey" to pubkey,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
|
||||
/**
|
||||
* Read verbs for relay groups: the user's joined list (kind 10009), a relay's
|
||||
* whole hosted directory, and a single group's metadata + roster. All reads drain
|
||||
* a one-shot subscription and parse with Quartz's NIP-29 events.
|
||||
*/
|
||||
object RelayGroupReadCommands {
|
||||
/** `relaygroup list` — the caller's joined groups from their kind-10009 list (public + private). */
|
||||
suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = ctx.outboxRelays()
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays configured; run `amy relay add`")
|
||||
|
||||
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
|
||||
val latest =
|
||||
ctx
|
||||
.drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<SimpleGroupListEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
|
||||
val groups =
|
||||
latest?.let { event ->
|
||||
val pub = event.publicGroups()
|
||||
val priv = event.privateGroups(ctx.signer) ?: emptyList()
|
||||
(pub + priv).distinctBy { it.groupId to it.relayUrl }
|
||||
} ?: emptyList()
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"count" to groups.size,
|
||||
"groups" to
|
||||
groups.map {
|
||||
mapOf("group_id" to it.groupId, "relay" to it.relayUrl, "name" to it.name)
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup browse RELAY [--timeout S]` — every group the relay hosts (its 39000 directory). */
|
||||
suspend fun browse(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup browse RELAY")
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
// NIP-29 requires the 39000 to be signed by the relay's own key; verify against the
|
||||
// relay's NIP-11 `self` so stray user-published 39000s on a general relay are dropped.
|
||||
val relaySigned = relaySignedFilter(ctx.relayInfo(relay))
|
||||
val filter = Filter(kinds = listOf(GroupMetadataEvent.KIND), limit = 500)
|
||||
val allMetas =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<GroupMetadataEvent>()
|
||||
.distinctBy { it.groupId() }
|
||||
val metas =
|
||||
allMetas
|
||||
.filter { relaySigned(it.pubKey) }
|
||||
.sortedBy { it.name()?.lowercase() ?: it.groupId() }
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"relay" to relay.url,
|
||||
"count" to metas.size,
|
||||
"unverified_dropped" to (allMetas.size - metas.size),
|
||||
"groups" to metas.map(::metaSummary),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup info RELAY GROUP_ID [--timeout S]` — one group's metadata + admin/member roster. */
|
||||
suspend fun info(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup info RELAY GROUP_ID")
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", "relaygroup info RELAY GROUP_ID")
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND),
|
||||
tags = mapOf("d" to listOf(groupId)),
|
||||
limit = 10,
|
||||
)
|
||||
val relaySigned = relaySignedFilter(ctx.relayInfo(relay))
|
||||
val events =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
// Only trust relay-signed metadata/roster (39xxx author == the relay's `self`).
|
||||
.filter { relaySigned(it.pubKey) }
|
||||
|
||||
val meta = events.filterIsInstance<GroupMetadataEvent>().maxByOrNull { it.createdAt }
|
||||
val admins = events.filterIsInstance<GroupAdminsEvent>().maxByOrNull { it.createdAt }?.admins() ?: emptyList()
|
||||
val members = events.filterIsInstance<GroupMembersEvent>().maxByOrNull { it.createdAt }?.members() ?: emptyList()
|
||||
val memberCount = (members + admins.map { it.pubKey }).distinct().size
|
||||
|
||||
if (meta == null && admins.isEmpty() && members.isEmpty()) {
|
||||
return Output.error("not_found", "no group $groupId found on ${relay.url}")
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"name" to meta?.name(),
|
||||
"about" to meta?.about(),
|
||||
"picture" to meta?.picture(),
|
||||
"private" to (meta?.isPrivate() ?: false),
|
||||
"closed" to (meta?.isClosed() ?: false),
|
||||
"member_count" to memberCount,
|
||||
"admins" to admins.map { mapOf("pubkey" to it.pubKey, "roles" to it.roles) },
|
||||
"members" to members,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A predicate for whether a 39xxx author is the host relay's approved key. NIP-29: metadata is
|
||||
* "signed by the relay keypair directly … as stated by the NIP-11 `self` pubkey". When the relay
|
||||
* publishes `self` we enforce it strictly; when it omits `self` we fall back to the weaker
|
||||
* "advertises NIP-29" signal; a relay with neither yields no genuine groups.
|
||||
*/
|
||||
private fun relaySignedFilter(info: Nip11RelayInformation?): (HexKey) -> Boolean {
|
||||
val self = info?.self
|
||||
return when {
|
||||
self != null -> { author -> author == self }
|
||||
info?.supported_nips?.any { it == "29" } == true -> { _ -> true }
|
||||
else -> { _ -> false }
|
||||
}
|
||||
}
|
||||
|
||||
private fun metaSummary(meta: GroupMetadataEvent) =
|
||||
mapOf(
|
||||
"group_id" to meta.groupId(),
|
||||
"name" to meta.name(),
|
||||
"about" to meta.about(),
|
||||
"private" to meta.isPrivate(),
|
||||
"closed" to meta.isClosed(),
|
||||
)
|
||||
}
|
||||
@@ -3,4 +3,5 @@ marmot/state-headless/
|
||||
dm/state-dm-headless/
|
||||
nests/state/
|
||||
clink/state-clink-headless/
|
||||
relaygroup/state-relaygroup-headless/
|
||||
sync/state-sync-deletions/
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# relaygroup-headless.sh — end-to-end check of `amy relaygroup` (NIP-29) against
|
||||
# an embedded relay (`amy serve`, which boots :geode). No external relay binary.
|
||||
#
|
||||
# What it proves:
|
||||
# 1. relaygroup create publishes 9007 + 9002 and returns a group_id.
|
||||
# 2. relaygroup message publishes a kind-9 chat scoped to the group.
|
||||
# 3. relaygroup join publishes 9021 AND adds the group to the kind:10009 list.
|
||||
# 4. relaygroup list reads that list back (private NIP-44 item decrypts).
|
||||
# 5. relaygroup browse emits well-formed JSON.
|
||||
#
|
||||
# geode is not a NIP-29 relay (it does not sign 39000-39003), so browse/info
|
||||
# return empty — that's a relay capability, not a client bug, and is out of
|
||||
# scope here. We assert the client's publish + list-maintenance round-trip.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state-relaygroup-headless"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
LOG_FILE="$LOG_DIR/run.log"
|
||||
RESULTS_FILE="$STATE_DIR/results"
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
PORT="${PORT:-7466}"
|
||||
NO_BUILD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift ;;
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
-h|--help) sed -n '3,17p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
rm -rf "$STATE_DIR"
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
: >"$LOG_FILE"
|
||||
: >"$RESULTS_FILE"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
|
||||
amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --json "$@"; }
|
||||
|
||||
SERVE_PID=""
|
||||
cleanup() {
|
||||
[[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null
|
||||
wait "$SERVE_PID" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$NO_BUILD" -eq 0 ]]; then
|
||||
step "building :cli:installDist"
|
||||
( cd "$REPO_ROOT" && ./gradlew -q :cli:installDist ) >>"$LOG_FILE" 2>&1 \
|
||||
|| { fail_msg "installDist failed (see $LOG_FILE)"; exit 1; }
|
||||
fi
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing at $AMY_BIN"; exit 1; }
|
||||
|
||||
banner "relaygroup headless (embedded relay on port $PORT)"
|
||||
|
||||
step "init account A"
|
||||
amy_a init >>"$LOG_FILE" 2>&1 || { fail_msg "init failed"; exit 1; }
|
||||
|
||||
step "boot embedded relay (amy serve)"
|
||||
HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext serve --port "$PORT" >"$LOG_DIR/serve.log" 2>&1 &
|
||||
SERVE_PID=$!
|
||||
for _ in $(seq 1 30); do grep -q "listening" "$LOG_DIR/serve.log" 2>/dev/null && break; sleep 0.5; done
|
||||
grep -q "listening" "$LOG_DIR/serve.log" || { fail_msg "relay did not start"; exit 1; }
|
||||
RELAY="ws://127.0.0.1:$PORT"
|
||||
|
||||
amy_a relay add "$RELAY" >>"$LOG_FILE" 2>&1 || { fail_msg "relay add failed"; exit 1; }
|
||||
|
||||
# 1. create
|
||||
step "relaygroup create"
|
||||
CREATE=$(amy_a relaygroup create "$RELAY" --name "Headless Group" --about "hi" --closed 2>>"$LOG_FILE")
|
||||
printf '%s\n' "$CREATE" >>"$LOG_FILE"
|
||||
GID=$(printf '%s' "$CREATE" | jq -r '.group_id // empty')
|
||||
if [[ -n "$GID" && "$(printf '%s' "$CREATE" | jq -r '.published')" == "true" ]]; then
|
||||
pass_msg "create → $GID"; record_result create pass
|
||||
else
|
||||
fail_msg "create did not publish"; record_result create fail
|
||||
fi
|
||||
|
||||
# 2. message
|
||||
step "relaygroup message"
|
||||
MSG=$(amy_a relaygroup message "$RELAY" "$GID" "gm from the harness" 2>>"$LOG_FILE")
|
||||
if [[ "$(printf '%s' "$MSG" | jq -r '.published')" == "true" && "$(printf '%s' "$MSG" | jq -r '.kind')" == "9" ]]; then
|
||||
pass_msg "message → kind 9 published"; record_result message pass
|
||||
else
|
||||
fail_msg "message did not publish"; record_result message fail
|
||||
fi
|
||||
|
||||
# 3. join (updates kind:10009)
|
||||
step "relaygroup join"
|
||||
JOIN=$(amy_a relaygroup join "$RELAY" "$GID" 2>>"$LOG_FILE")
|
||||
if [[ "$(printf '%s' "$JOIN" | jq -r '.published')" == "true" && "$(printf '%s' "$JOIN" | jq -r '.listed')" == "true" ]]; then
|
||||
pass_msg "join → published + listed"; record_result join pass
|
||||
else
|
||||
fail_msg "join did not publish/list"; record_result join fail
|
||||
fi
|
||||
|
||||
# 4. list (reads kind:10009 back, decrypting the private item)
|
||||
step "relaygroup list"
|
||||
LIST=$(amy_a relaygroup list 2>>"$LOG_FILE")
|
||||
if printf '%s' "$LIST" | jq -e --arg g "$GID" '.groups[] | select(.group_id == $g)' >/dev/null 2>&1; then
|
||||
pass_msg "list contains the joined group"; record_result list pass
|
||||
else
|
||||
fail_msg "list missing the joined group: $LIST"; record_result list fail
|
||||
fi
|
||||
|
||||
# 5. browse emits well-formed JSON (empty against geode)
|
||||
step "relaygroup browse"
|
||||
BROWSE=$(amy_a relaygroup browse "$RELAY" --timeout 3 2>>"$LOG_FILE")
|
||||
if printf '%s' "$BROWSE" | jq -e '.count != null' >/dev/null 2>&1; then
|
||||
pass_msg "browse emitted well-formed JSON"; record_result browse pass
|
||||
else
|
||||
fail_msg "browse JSON malformed: $BROWSE"; record_result browse fail
|
||||
fi
|
||||
|
||||
print_summary
|
||||
# results are "<id>\t<pass|fail|skip>"; any fail row → nonzero exit.
|
||||
awk -F'\t' '$2=="fail"{f=1} END{exit f}' "$RESULTS_FILE" && exit 0 || exit 1
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* How a NIP-29 group qualifies for a discovery view, evaluated against a [RelayGroupChannel].
|
||||
*
|
||||
* A group's kind-39000 is relay-signed, so the naive "author = a follow" match the note feeds use
|
||||
* never fires (the author is the relay). But the *people* dimension is still meaningful — it lives
|
||||
* in the roster events and the relay's own key:
|
||||
* - the relay signing the 39000 may be a followed key ([ByPeople] relay-key path);
|
||||
* - a follow may be a group **admin** (kind 39001) or **member** (kind 39002).
|
||||
*
|
||||
* Topics/geo aren't defined by NIP-29, but a cooperating relay can copy requested `t`/`g` tags onto
|
||||
* the 39000 ([ByHashtags]/[ByGeohashes]).
|
||||
*
|
||||
* This is the platform-agnostic match; front ends map their own top-nav filter onto these (see the
|
||||
* Android `toGroupConstraints` mapping) and Desktop/CLI can reuse the same matcher.
|
||||
*/
|
||||
sealed interface GroupDiscoveryConstraint {
|
||||
fun matches(channel: RelayGroupChannel): Boolean
|
||||
|
||||
/** Every group the relay hosts (Global, or a specific relay chip). */
|
||||
data object AllGroups : GroupDiscoveryConstraint {
|
||||
override fun matches(channel: RelayGroupChannel) = channel.event != null
|
||||
}
|
||||
|
||||
/** I follow the relay key, or a follow is an admin/member of the group. */
|
||||
data class ByPeople(
|
||||
val pubkeys: Set<HexKey>,
|
||||
) : GroupDiscoveryConstraint {
|
||||
override fun matches(channel: RelayGroupChannel): Boolean {
|
||||
if (pubkeys.isEmpty()) return false
|
||||
val relayKey = channel.event?.pubKey
|
||||
return (relayKey != null && relayKey in pubkeys) ||
|
||||
channel.admins.any { it.pubKey in pubkeys } ||
|
||||
channel.members.any { it in pubkeys }
|
||||
}
|
||||
}
|
||||
|
||||
/** The 39000 carries a `t` tag matching one of these topics (compared lowercase). */
|
||||
data class ByHashtags(
|
||||
val hashtags: Set<String>,
|
||||
) : GroupDiscoveryConstraint {
|
||||
private val lower = hashtags.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
|
||||
override fun matches(channel: RelayGroupChannel): Boolean {
|
||||
if (lower.isEmpty()) return false
|
||||
return channel.event?.hashtags()?.any { it.lowercase() in lower } == true
|
||||
}
|
||||
}
|
||||
|
||||
/** The 39000 carries a `g` tag matching one of these geohashes (mip-map prefixes intersect). */
|
||||
data class ByGeohashes(
|
||||
val geohashes: Set<String>,
|
||||
) : GroupDiscoveryConstraint {
|
||||
private val lower = geohashes.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
|
||||
override fun matches(channel: RelayGroupChannel): Boolean {
|
||||
if (lower.isEmpty()) return false
|
||||
return channel.event?.geohashes()?.any { it.lowercase() in lower } == true
|
||||
}
|
||||
}
|
||||
|
||||
/** All-follows big-OR: a group matches if ANY of its people/topic/geo lenses match. */
|
||||
data class AnyOf(
|
||||
val constraints: List<GroupDiscoveryConstraint>,
|
||||
) : GroupDiscoveryConstraint {
|
||||
override fun matches(channel: RelayGroupChannel) = constraints.any { it.matches(channel) }
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A NIP-29 relay-based group ("channel" in Discord terms). Unlike a NIP-C7
|
||||
* [com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel] — a
|
||||
* bare relay+room with no server-owned metadata — a relay group has a
|
||||
* relay-signed metadata event (kind 39000) carrying its name, picture, about and
|
||||
* status flags, so this channel is metadata-backed like a NIP-28
|
||||
* [com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel].
|
||||
*
|
||||
* It is keyed by [GroupId] (host relay + group id): the group only exists on its
|
||||
* host relay, so every read and write is pinned there via [relays].
|
||||
*/
|
||||
@Stable
|
||||
class RelayGroupChannel(
|
||||
val groupId: GroupId,
|
||||
) : Channel() {
|
||||
/** The latest relay-signed kind 39000 metadata event, when known. */
|
||||
var event: GroupMetadataEvent? = null
|
||||
|
||||
/** Long-term reference to the metadata note (LocalCache holds weak references). */
|
||||
var metadataNote: Note? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
private set
|
||||
|
||||
/** Relay-signed member pubkeys (kind 39002). */
|
||||
var members: Set<HexKey> = emptySet()
|
||||
private set
|
||||
private var membersUpdatedAt: Long = 0
|
||||
|
||||
/** Relay-signed admins with their roles (kind 39001). */
|
||||
var admins: List<GroupAdminTag> = emptyList()
|
||||
private set
|
||||
private var adminsUpdatedAt: Long = 0
|
||||
|
||||
/**
|
||||
* Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery
|
||||
* feed read this per note / per recomposition, so caching it avoids rebuilding the set each read.
|
||||
*/
|
||||
private var allMembers: Set<HexKey> = emptySet()
|
||||
|
||||
private fun recomputeAllMembers() {
|
||||
allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind-11 threads (forum-style posts) scoped to this group, kept apart from the
|
||||
* kind-9 chat [Channel.notes] so the chat feed stays clean. Surfaced through the
|
||||
* group's "Threads" view; each thread's own comment tree (kind 1111) is loaded
|
||||
* on demand by the thread-detail screen.
|
||||
*/
|
||||
private val threadNotes = LargeCache<HexKey, Note>()
|
||||
private val _threads = MutableStateFlow<List<Note>>(emptyList())
|
||||
val threads: StateFlow<List<Note>> = _threads
|
||||
|
||||
private fun republishThreads() {
|
||||
_threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L }
|
||||
}
|
||||
|
||||
/** Add a kind-11 thread note; newest-first snapshot is re-published to [threads]. */
|
||||
fun addThread(note: Note) {
|
||||
if (threadNotes.containsKey(note.idHex)) return
|
||||
threadNotes.put(note.idHex, note)
|
||||
republishThreads()
|
||||
}
|
||||
|
||||
fun removeThread(note: Note) {
|
||||
if (!threadNotes.containsKey(note.idHex)) return
|
||||
threadNotes.remove(note.idHex)
|
||||
republishThreads()
|
||||
}
|
||||
|
||||
fun threadCount(): Int = threadNotes.size()
|
||||
|
||||
/** A relay group lives on exactly one relay: its host. */
|
||||
override fun relays() = setOf(groupId.relayUrl)
|
||||
|
||||
override fun toBestDisplayName(): String = event?.name() ?: groupId.id
|
||||
|
||||
fun summary(): String? = event?.about()
|
||||
|
||||
fun profilePicture(): String? = event?.picture()
|
||||
|
||||
fun isPrivate(): Boolean = event?.isPrivate() ?: false
|
||||
|
||||
fun isRestricted(): Boolean = event?.isRestricted() ?: false
|
||||
|
||||
fun isClosed(): Boolean = event?.isClosed() ?: false
|
||||
|
||||
fun isHidden(): Boolean = event?.isHidden() ?: false
|
||||
|
||||
fun hasLivekit(): Boolean = event?.hasLivekit() ?: false
|
||||
|
||||
fun updateGroupInfo(
|
||||
event: GroupMetadataEvent,
|
||||
eventNote: Note? = null,
|
||||
) {
|
||||
// Only newer metadata supersedes; equal-or-older is dropped, so a duplicate
|
||||
// arrival isn't reprocessed (no redundant emit) and first-arrival wins on a
|
||||
// createdAt tie. First load passes since real events have createdAt > 0.
|
||||
if (event.createdAt <= updatedMetadataAt) return
|
||||
this.event = event
|
||||
this.metadataNote = eventNote
|
||||
this.updatedMetadataAt = event.createdAt
|
||||
updateChannelInfo()
|
||||
}
|
||||
|
||||
fun updateMembers(event: GroupMembersEvent) {
|
||||
if (event.createdAt <= membersUpdatedAt) return
|
||||
members = event.members().toSet()
|
||||
membersUpdatedAt = event.createdAt
|
||||
recomputeAllMembers()
|
||||
updateChannelInfo()
|
||||
}
|
||||
|
||||
fun updateAdmins(event: GroupAdminsEvent) {
|
||||
if (event.createdAt <= adminsUpdatedAt) return
|
||||
admins = event.admins()
|
||||
adminsUpdatedAt = event.createdAt
|
||||
recomputeAllMembers()
|
||||
updateChannelInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* The shareable NIP-19 `naddr` coordinate for this group's metadata (kind
|
||||
* 39000, authored by the relay's own key, with the host relay as a hint), or
|
||||
* null until the relay-signed metadata has loaded (we need its author key).
|
||||
*/
|
||||
fun toNAddr(): String? {
|
||||
val relaySelf = event?.pubKey ?: return null
|
||||
return NAddress.create(GroupMetadataEvent.KIND, relaySelf, groupId.id, groupId.relayUrl)
|
||||
}
|
||||
|
||||
/** Number of known members (admins are members too). */
|
||||
fun memberCount(): Int = allMembers.size
|
||||
|
||||
/**
|
||||
* The subset of this group's members/admins that [follows] contains — "people you follow who
|
||||
* are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition.
|
||||
*/
|
||||
fun participatingFollows(follows: Set<HexKey>): List<HexKey> {
|
||||
if (follows.isEmpty() || allMembers.isEmpty()) return emptyList()
|
||||
return allMembers.filter { it in follows }
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's view of [pubkey]'s membership, from the signed admin/member
|
||||
* lists. Returns [RelayGroupMembership.NONE] when not in either list (or when
|
||||
* the lists haven't loaded yet).
|
||||
*/
|
||||
fun membershipOf(pubkey: HexKey): RelayGroupMembership {
|
||||
val admin = admins.firstOrNull { it.pubKey == pubkey }
|
||||
if (admin != null) {
|
||||
return when {
|
||||
admin.roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> RelayGroupMembership.ADMIN
|
||||
// Presence in the kind-39001 admins list IS the moderation signal;
|
||||
// the role labels (moderator, ceo, owner, …) are relay-defined. So
|
||||
// anyone in that list who isn't the top-level admin is at least a
|
||||
// moderator — never demote them to a plain member just because the
|
||||
// role string is unrecognized or absent.
|
||||
else -> RelayGroupMembership.MODERATOR
|
||||
}
|
||||
}
|
||||
return if (pubkey in members) RelayGroupMembership.MEMBER else RelayGroupMembership.NONE
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean =
|
||||
groupId.id.contains(prefix, true) ||
|
||||
event?.name()?.contains(prefix, true) == true ||
|
||||
event?.about()?.contains(prefix, true) == true
|
||||
|
||||
// Synthetic note representing this group in list views (the Messages tab) before any message
|
||||
// has loaded — so a group the user just joined shows up immediately instead of waiting for its
|
||||
// first cached kind-9. Mirrors MarmotGroupChatroom.placeholderNote(): adds this channel as a
|
||||
// gatherer so the list row resolves back to it, and is cached with a stable id so equality-based
|
||||
// feed diffing treats it as the same row across refreshes.
|
||||
private val placeholderLock = KmpLock()
|
||||
private var cachedPlaceholder: Note? = null
|
||||
|
||||
fun placeholderNote(): Note =
|
||||
placeholderLock.withLock {
|
||||
cachedPlaceholder ?: Note(placeholderIdHex(groupId)).apply {
|
||||
addGatherer(this@RelayGroupChannel)
|
||||
cachedPlaceholder = this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun placeholderIdHex(groupId: GroupId): HexKey = "relaygroup-empty-${groupId.toKey()}"
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.groupSet
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.groups
|
||||
|
||||
/**
|
||||
* Decrypt-once cache for the NIP-51 simple-groups list (kind 10009). The list's
|
||||
* joined groups can be public tags or NIP-44 private items (encrypted to self);
|
||||
* this merges both and parses the `group` items, memoizing the decrypt so the
|
||||
* always-on observers don't each pay a signer round-trip.
|
||||
*/
|
||||
class RelayGroupListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<SimpleGroupListEvent>(signer)
|
||||
|
||||
fun cachedGroupSet(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagListPrecached(event).groupSet()
|
||||
|
||||
fun cachedGroups(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagListPrecached(event).groups()
|
||||
|
||||
suspend fun groupSet(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagList(event).groupSet()
|
||||
|
||||
suspend fun groups(event: SimpleGroupListEvent) = cachedPrivateLists.mergeTagList(event).groups()
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Persistence hook for the last-known kind 10009 event (offline backup). */
|
||||
interface RelayGroupRepository {
|
||||
fun relayGroupList(): SimpleGroupListEvent?
|
||||
|
||||
fun updateRelayGroupListTo(newRelayGroupList: SimpleGroupListEvent?)
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's NIP-51 "simple groups" list (kind 10009): the cross-device source
|
||||
* of truth for which NIP-29 groups they've joined. Joined groups are written as
|
||||
* PUBLIC `["group", id, relay, name?]` tags (see [follow] for why); reads still
|
||||
* merge in any NIP-44-encrypted private items so older lists keep working. Mirrors
|
||||
* [com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState].
|
||||
*/
|
||||
class RelayGroupListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val decryptionCache: RelayGroupListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: RelayGroupRepository,
|
||||
) {
|
||||
// Long-term reference so the GC doesn't collect the note itself.
|
||||
val relayGroupListNote = cache.getOrCreateAddressableNote(getRelayGroupListAddress())
|
||||
|
||||
fun getRelayGroupListAddress() = SimpleGroupListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getRelayGroupListFlow(): StateFlow<NoteState> = relayGroupListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getRelayGroupList(): SimpleGroupListEvent? = relayGroupListNote.event as? SimpleGroupListEvent
|
||||
|
||||
suspend fun relayGroupListWithBackup(note: Note): Set<GroupTag> {
|
||||
val event = note.event as? SimpleGroupListEvent ?: settings.relayGroupList()
|
||||
return event?.let { decryptionCache.groupSet(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveRelayGroupList: StateFlow<Set<GroupTag>> =
|
||||
getRelayGroupListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(relayGroupListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(relayGroupListWithBackup(relayGroupListNote))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
/** The distinct host relays across all joined groups — the "servers" rail. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveRelayGroupServers: StateFlow<Set<String>> =
|
||||
liveRelayGroupList
|
||||
.transformLatest { groups -> emit(groups.mapTo(mutableSetOf()) { it.relayUrl }) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
private fun RelayGroupChannel.toGroupTag() = GroupTag(groupId.id, groupId.relayUrl.url, event?.name())
|
||||
|
||||
suspend fun follow(channel: RelayGroupChannel): SimpleGroupListEvent {
|
||||
val relayGroupList = getRelayGroupList()
|
||||
val group = channel.toGroupTag()
|
||||
|
||||
// Store joined groups as PUBLIC `["group", id, relay]` tags. NIP-29 group
|
||||
// membership is already public on the relay (the kind-39002 members list),
|
||||
// so private storage buys almost no privacy — and the reference NIP-29
|
||||
// clients (Flotilla, nostrord) read only the public tags, so a private list
|
||||
// makes our joined groups invisible to them. Reading still merges public +
|
||||
// any legacy private items, so existing private entries keep working.
|
||||
return if (relayGroupList == null) {
|
||||
SimpleGroupListEvent.create(
|
||||
publicGroups = listOf(group),
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
SimpleGroupListEvent.add(
|
||||
earlierVersion = relayGroupList,
|
||||
group = group,
|
||||
isPrivate = false,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(channel: RelayGroupChannel): SimpleGroupListEvent? {
|
||||
val relayGroupList = getRelayGroupList() ?: return null
|
||||
return SimpleGroupListEvent.remove(
|
||||
earlierVersion = relayGroupList,
|
||||
group = channel.toGroupTag(),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
settings.relayGroupList()?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved relay group list")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "RelayGroupList Collector Start")
|
||||
getRelayGroupListFlow().collect { noteState ->
|
||||
Log.d("AccountRegisterObservers") { "RelayGroupList for ${signer.pubKey}" }
|
||||
(noteState.note.event as? SimpleGroupListEvent)?.let {
|
||||
settings.updateRelayGroupListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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"
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -223,7 +223,7 @@ class RichTextParser {
|
||||
|
||||
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
|
||||
|
||||
val allUrls = urlSet.withScheme + urlSet.withoutScheme + urlSet.emails + urlSet.bech32s + urlSet.relayUrls + urlSet.blossomUris
|
||||
val allUrls = urlSet.withScheme + urlSet.withoutScheme + urlSet.emails + urlSet.bech32s + urlSet.relayUrls + urlSet.blossomUris + urlSet.groupLinks
|
||||
|
||||
val newContent = fixMissingSpaces(content, allUrls)
|
||||
|
||||
@@ -377,6 +377,10 @@ class RichTextParser {
|
||||
|
||||
if (urls.bech32s.contains(word)) return BechSegment(word)
|
||||
|
||||
// Checked before relayUrls: a group link `wss://relay'groupId` embeds a relay URL,
|
||||
// so it must win over the bare relay-URL interpretation.
|
||||
if (urls.groupLinks.contains(word)) return RelayGroupLinkSegment(word)
|
||||
|
||||
if (urls.relayUrls.contains(word)) return RelayUrlSegment(word)
|
||||
|
||||
if (urls.blossomUris.contains(word)) return BlossomUriSegment(word)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user