Commit Graph
16309 Commits
Author SHA1 Message Date
Claude 7e680ddea0 feat(nip29): richer group card + threads/browse polish
Card glow-up (from the UI review): ElevatedCard with a subtle avatar ring, the
name promoted to titleMedium, a tonal "Private"/"Invite-only" status pill, and a
member-count chip with a people icon + primary-tinted chevron — so the inline
card reads as a living community, not a link row. Still fixed-layout / no reflow.

Threads: gate the compose FAB on membership (a non-member's kind-11 is rejected
by the relay), make per-thread reply counts reactive via observeNoteReplyCount
(so a new kind-1111 comment bumps the count live), and use leading dividers.

Browse: show an inline error when the pasted relay URL doesn't normalize, and
drop a redundant Row wrapper around the text field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 19:06:18 +00:00
Claude 15c336e623 fix(nip29): live roster/metadata loading + empty states on group screens
Three review-found reactivity/loading defects:

- Members screen mounted no per-group roster subscription — observeChannel is a
  no-op for a RelayGroupChannel, so the roster only appeared if the chat screen
  had cached it. Mount RelayGroupPreviewSubscription so it fetches kinds
  39001/39002 from the host relay while visible; add a loading state and the
  group name in the subtitle.
- Invite dialog read toNAddr()/isClosed() as one-shot snapshots, so opening it
  before the kind-39000 arrived left the Copy button permanently disabled. Collect
  the metadata flow so the naddr/code fill in live; show a "Preparing invite…"
  placeholder meanwhile; move to the non-deprecated LocalClipboard.
- Channel-list polled the cache every 1500ms and rendered a blank screen when
  empty. Drive refresh off LocalCache.observeEvents(kind 39000) instead of a
  timer, sort the initial value (no first-frame reshuffle), and add an empty
  state. Both lists now use leading dividers (no trailing divider under the last
  row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:59:51 +00:00
Claude 34dfd691d3 refactor(nip29): render group-card description only when present
Drop the reserved two-line description block: description-less groups now stay
compact instead of showing two blank lines. The about still fills in when the
metadata loads (a small one-time grow), which is the better trade for the common
case where a group has no description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:18:17 +00:00
Claude 7ea266f811 feat(nip29): show group description on the inline group card
The group-link card now renders the group's about/description below the header
(avatar + name + relay · members). The description occupies a reserved two-line
block so it fills in when the relay-signed metadata arrives without changing the
card's height — preserving the no-layout-shift behavior while making the card
substantially richer than the bare URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:14:46 +00:00
Claude 51299400ef feat(nip29): one-tap join from invite links carrying a code
An invite link's `?code=` now flows all the way to the join: tapping a
`wss://relay'id?code=…` card/link (or opening it as a deep link) opens the group
and, because the tap is itself the opt-in, fires the kind-9021 join with that
code once — no more re-typing it into the join dialog. Plain (code-less) group
links still just open the group for viewing.

- Route.RelayGroup gains inviteCode; threaded through AppNavigation →
  RelayGroupChatScreen → RelayGroupTopBar
- RelayGroupTopBar auto-joins once when a code is present and we're not already
  a member (reuses the optimistic "requested" state)
- RelayGroupCard / ClickableRelayGroupLink / uriToRoute carry the parsed code

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:05:00 +00:00
Claude 2b11dbd7d9 test(nip29): cover weird apostrophe placements + guard relay-URL possessives
Reject a single-character group id (except the default `_`) so a possessive
glued to a bare relay URL — `wss://relay.damus.io's uptime` — no longer
linkifies group "s". Real ids (relay29/Wisp/0xchat) are all longer.

Adds coverage proving only genuine ws/wss relay URLs are peeked: apostrophes
after http, nostr:, blossom:, email and bech32 tokens never become group links;
plus ws:// (insecure), second-apostrophe boundary, and multi-link cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 17:21:15 +00:00
Claude 9b7d2f7e45 feat(nip29): render inline group links as self-loading group cards
In the preview render path, a `<relay>'<groupId>` link now draws as a
full-width group card instead of a bare link. The card renders immediately
with a stable layout — robohash avatar seeded from the group id, the id as a
placeholder name, and the host relay — then fills in the real name, picture and
member count in place as the relay-signed metadata arrives, without changing
the card's structure or height.

While a card is on screen it warms the group via a lightweight, host-pinned
compose subscription: keeps kind 39000/39001/39002 + roles live so the card
stays current, and prefetches the newest ~15 chat messages / threads so tapping
the card opens an already-populated screen. Non-preview contexts (e.g. DMs)
keep the plain clickable link and issue no outbound subscription.

- RelayGroupPreviewFilterAssembler / RelayGroupPreviewSubscription (registered
  in RelaySubscriptionsCoordinator)
- RelayGroupCard + RichTextViewer preview-path branch
- relay_group_open string

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 17:10:26 +00:00
Claude f9b24156e8 feat(nip29): linkify group invite links inline and via deep links
Recognise the de-facto `<relay>'<groupId>[?code=<code>]` NIP-29 group invite
link format used by Wisp and 0xchat, both inside rendered note content and as
an external deep link, so tapping one opens the group.

The URL detector correctly stops a host at the apostrophe (host names can't
contain `'`), so the group id is torn off before classification. Rather than
loosen the shared URL grammar — which would swallow prose possessives like
`example.com's` — group links are recovered by peeking just past each relay
URL the detector already found. This is cache-miss-only and costs nothing on
notes without a `wss://` link.

- quartz: GroupInviteLink.parse / suffixLength (+ tests)
- commons: Urls.groupLinks, UrlParser peek, RichTextParser plumbing
  (atomic span through fixMissingSpaces, new RelayGroupLinkSegment) (+ tests)
- amethyst: ClickableRelayGroupLink renderer + uriToRoute deep-link branch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 16:15:31 +00:00
Claude e4a6e13036 fix: nostrord interop — thread titles and public group list
Two compatibility gaps found analyzing nostrord (a NIP-29 client):

- Thread titles: NIP-7D (and Amethyst) use a `title` tag, but nostrord
  writes/reads `subject`, so neither showed the other's thread titles.
  ThreadEvent.title() now reads `title` OR `subject`; we still emit only the
  spec-correct `title`.
- Joined-groups list (kind 10009): Amethyst wrote memberships as NIP-44
  private items, but both reference clients (Flotilla, nostrord) store — and
  nostrord only READS — public `["group", id, relay]` tags, so an Amethyst
  user's groups were invisible to them. follow() (and the amy CLI) now write
  public tags. NIP-29 membership is already public via the relay's kind-39002
  list, so this loses no real privacy; reads still merge any legacy private
  items so existing lists keep working.

Tests: read title from title/subject (title wins; we emit title only); public
group is a plain tag and still read through the cache; a mixed public+private
list reads as both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 14:21:42 +00:00
Claude e43c752a07 fix: keep group thread replies scoped to the group
Replying to a NIP-29 group thread went through the shared NIP-22 comment
composer, which built a plain kind-1111 comment with no group `h` tag and
broadcast it to the author's outbox. Such a reply is not group content: the
host relay rejects it and no other member — or other NIP-29 client like
Flotilla — ever sees it, and for a private/closed group it leaks to
unrelated relays.

Both fixes are tightly guarded on the replied-to event being group-scoped,
so ordinary comments are untouched:
- CommentPostViewModel inherits the group's `h` tag from the event being
  replied to (covers replies to the kind-11 root and to nested 1111
  comments — both route here).
- The reply is published only to the group's host relay (the relay the
  thread was seen on) via signAndSendPrivatelyOrBroadcast, instead of the
  outbox-computing broadcast — so it reaches the group and never leaks.

Verified: quartz test builds the reply as the composer does
(CommentEvent.replyBuilder { hTag } over a kind-11 root) and asserts it is a
1111 carrying the group `h` tag and referencing the thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 13:36:10 +00:00
Claude 2fb6e3cb2f feat: relay-group threads (kind 11) as a secondary view
Closes the Flotilla interop gap where NIP-29 groups also carry kind-11
"threads" (forum-style posts) that Amethyst's chat-only room view dropped.
Threads are a secondary surface, kept out of the kind-9 chat feed — a
Threads button in the group top bar, mirroring Discord/Slack.

- RelayGroupChannel: a separate `threads` collection (kind-11 notes) with a
  reactive StateFlow, distinct from the chat timeline.
- LocalCache: attach kind-11 to channel.threads (same host-pinned + own-send
  null-relay routing as chat messages).
- Host-pinned RelayGroupThreadsFilterAssembler (kinds 11 + 1111 scoped by
  `#h`), active only while a group's Threads screen is open; fetching the
  1111 comments too means opening a thread has its replies already cached.
- RelayGroupThreadsScreen lists a group's threads (title, author, preview,
  reply count) and opens each in the existing thread view (Route.Note) for
  the full comment tree — no bespoke detail screen needed. Members start a
  thread via NewRelayGroupThreadDialog → Account.postRelayGroupThread
  (ThreadEvent.build(body, title){ hTag }).
- Route.RelayGroupThreads + a Forum icon in the group top bar.

Verified: kind-11 with h + title round-trips and is queryable by #h against
an embedded relay (the exact filter Flotilla uses); commons threads-collection
test (dedup/flow/remove) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 13:15:23 +00:00
Claude 633903b5c1 fix: relay-group audit — timeline, roster, membership, list-safety
Fixes found in a full audit of the NIP-29 relay-groups feature across
quartz/commons/amethyst/cli.

Correctness (app):
- Own group messages never appeared in the timeline until an app restart:
  the optimistic send is consumed with a null relay, so attachToRelayGroup
  bailed on the relay==null guard, and the host relay's echo (new==false)
  was skipped by the "only attach when newly consumed" gate. Attach now runs
  on every arrival, gated on the note being loaded, and the null-relay case
  attaches to the already-open channel(s) for that group id. Also avoids the
  wrong-relay phantom by only fabricating a channel from real provenance.
- Roster subscription was frozen after an in-place join/leave (state keyed
  on the stable account, never re-derived); it now invalidates on every
  liveRelayGroupList change, so a fresh join's 39002 admission is fetched.
- membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER,
  hiding moderation; presence in the admins list now means at least MODERATOR.
- Members roster showed permanent truncated-hex names (one-shot
  getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay
  fills in when kind:0 arrives.

Protocol / data:
- GroupTag had no value equality → joined-group Sets never deduped and the
  StateFlow re-emitted on every identical re-arrival. Equality is now the
  (id, relay) pair, excluding the cosmetic name.
- create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29
  flags are presence-only, so only private/closed are emitted when set.
- Metadata/member/admin supersede guards use <= so an equal-createdAt
  duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now
  private-set. Relay-group channels are now included in the prune loops.

CLI:
- join/leave/create updated the kind:10009 list from a network-only drain;
  a slow/empty fetch could publish a fresh list containing ONLY the new
  group, wiping the rest. Now reads the local store (source of truth) too.
- edit re-asserted both visibility axes from flag presence, so --closed on a
  private group leaked it public. It now reads current 39000 and merges,
  with --public/--open counter-flags; only the specified axis changes.
- create now tracks the new group in kind:10009 (parity with join/Android).

UI polish:
- Invite dialog no longer mints a 9009 for open groups and won't copy a code
  it never displayed. Browse "popular" list normalizes URLs before filtering.

Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt
no-resupersede added; all quartz+commons NIP-29 suites and the amy
relaygroup harness pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 02:27:18 +00:00
Claude d85bac1f55 feat(cli): amy relaygroup — NIP-29 relay groups in the CLI
Adds a first-class `amy relaygroup` verb group so the CLI can drive the
same NIP-29 relay groups as the app. Thin assembly over quartz builders +
Context.publish/drain — no protocol logic in cli/.

Verbs:
- list / browse RELAY / info RELAY GID — reads (joined kind:10009 list with
  private-item decryption; a relay's 39000-39003 directory; one group's
  metadata + roster).
- create / join / leave / message — lifecycle. join/leave also maintain the
  caller's kind:10009 list (add/remove private item) so `list` reflects them,
  mirroring the app's follow/unfollow.
- edit / invite / put-user / remove-user — moderation (9002/9009/9000/9001).

All writes pin to the group's single host relay. Output follows amy's
text/--json contract with snake_case keys.

Verified end-to-end against an embedded relay (amy serve) with a new
self-contained harness, cli/tests/relaygroup/relaygroup-headless.sh:
create/message/join/list/browse all pass (5/5). browse/info return empty
against geode since it doesn't sign 39000-39003 — a relay capability, not a
client issue. README command table + ROADMAP updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 01:41:15 +00:00
Claude 7ffc38f55d feat: promote Relay Groups to a top-level destination + live rosters
Renames the feature to "Relay Groups" and makes it a first-class
navigation destination rather than a Messages-tab sub-view, and keeps
joined-group membership accurate app-wide.

- Top-level Route.RelayGroups home (RelayGroupsHomeScreen): a root
  destination with the drawer top bar + bottom bar, listing the host
  relays of joined groups (each drilling into its channels) plus the
  discovery entry. Registered as NavBarItem.RELAY_GROUPS (Forum icon) in
  the drawer's Feeds section and pinnable to the bottom bar, with a
  roster preloader entry.
- Naming: user-facing strings now say "group(s)" instead of "channel(s)";
  added relay_groups_title = "Relay Groups".
- Live rosters: RelayGroupRosterFilterAssembler keeps every joined
  group's 39000/39001/39002 fresh (one #d-scoped filter per host relay,
  re-derived from the join list) while a groups-bearing screen is on top.
  Mounted on both Messages panes and the new home, so membership,
  pending→member transitions and member counts stay accurate without
  opening each chat — the gap that most affected closed/private groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 01:25:27 +00:00
Claude f7be77c6e7 test: cover NIP-29 client-side membership and private-list round-trip
Adds runnable jvmTest coverage for the commons logic behind relay groups,
which until now was only compile-verified:

- RelayGroupChannelTest: the roster fold in RelayGroupChannel — role
  derivation (admin/moderator/plain member), an admin present only in the
  39001 list still resolving as a member, member-count dedup across
  admins+members, and the createdAt supersede guards dropping stale
  out-of-order 39000/39002 events.
- RelayGroupListDecryptionTest: drives the exact create/add/remove calls
  join/leave delegate to, then reads them back through the decryption
  cache, proving a followed group survives the NIP-44 encrypt→sign→decrypt
  round-trip and that unfollow removes only the intended group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 01:04:00 +00:00
Claude d6b76c400f feat: make relay groups discoverable, shareable and deep-linkable
Turns NIP-29 groups from a joined-only feature into something users can
find, share and open from a link:

- RelayGroupBrowseScreen (Route.RelayGroupBrowse): paste any relay URL to
  browse the channels it hosts, pick a relay you're already on, or try a
  popular public relay. Reached from a new "Find channels" FAB entry and
  from the grouped server list.
- RelayGroupServerList always shows a "Find channels" row (even with no
  joined groups), so a new user has a starting point instead of an empty
  view; extracted RelayGroupServerRow for reuse by the browse screen.
- Share action in the group top bar: fires the system share sheet with a
  njump link to the group's naddr, available to members and non-members.
- Cold-start deep links: a kind-39000 group naddr now routes straight to
  the group chat using the naddr's relay hint, instead of falling through
  to the generic note-redirect screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 23:22:20 +00:00
Claude 21387569e9 feat: add NIP-29 group roster and admin moderation UI
Adds a members roster screen and admin/moderator actions for relay-based
groups:

- Account: removeRelayGroupUser (kind 9001), putRelayGroupUser (kind 9000
  promote/demote), editRelayGroupMetadata (kind 9002), all pinned to the
  group's host relay; plus AccountViewModel wrappers.
- RelayGroupMembersScreen: relay-signed roster (39001 admins / 39002
  members) with avatars, names and role badges. Moderators get a per-user
  menu to promote to admin/moderator, remove a role, or kick (with a
  confirm dialog). Menu items are gated so a moderator can't act on an
  admin and nobody acts on themselves.
- EditRelayGroupDialog: admin-only edit of name/topic/visibility.
- Wire Members, Edit channel (admin) and existing Invite/Leave into the
  chat top bar overflow; new Route.RelayGroupMembers registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:56:21 +00:00
Claude 7eefdd2fb5 feat(nip29): visual polish pass on the relay-group UI
Replace the plain, glyph-based rows with real iconography and avatars (all icons
reuse the existing MaterialSymbols subset — no font regen):

- Relay rows (grouped view): the relay's NIP-11 avatar + name, host subtitle, and
  a ChevronRight icon instead of a "›" character.
- Channel-browse rows: a channel avatar, a private-lock glyph, member count, and a
  "joined" check; the FAB uses the Add icon instead of a "+" character.
- Chat top bar: a real MoreVert overflow icon; the header now shows a colored role
  pill (Admin/Moderator/Requested), a members count with a Group icon, and a lock
  for private groups — replacing the flat "host · N members · role" text.
- Inline row's relay chip gains a small Dns icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:45:50 +00:00
Claude 2dca306914 feat(nip29): shareable group naddr + invite-code join flow
Nostr-native invites/deep-links for relay groups (no proprietary URL scheme):

- RelayGroupChannel.toNAddr(): a NIP-19 `naddr` for the group's kind-39000
  metadata (authored by the relay key, host relay as hint) — a cross-client
  coordinate that opens the group.
- ClickableRoute: a clicked/rendered `naddr` for kind 39000 routes straight into
  the group chat (Route.RelayGroup with the relay hint) instead of the generic
  addressable-note view.
- InviteRelayGroupDialog now shares the group `nostr:naddr…` (for discovery) plus
  the one-time code for closed groups; copy grabs both.
- JoinRelayGroupDialog: closed groups prompt for the invite code, which the join
  request (9021) carries; open groups still join in one tap.

Follow-up: cold-start `nostr:naddr` deep links (from outside the app) still route
to the generic note view; only the in-app clicked/rendered path is wired here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:36:07 +00:00
Claude 7c0f24ef57 feat(nip29): accurate membership state from the relay roster
Membership is now derived from the relay's own signed lists (kind 39001 admins /
39002 members) instead of the client's kind-10009 intent:

- RelayGroupChannel gains members/admins (from 39002/39001), a RelayGroupMembership
  derivation (ADMIN/MODERATOR/MEMBER/NONE, + a client-side PENDING), and a member
  count. LocalCache consumes 39001/39002 into the channel.
- RelayGroupTopBar shows the real state: member count and your role in the
  subtitle; a Join button when you're not a member, an optimistic "Requested"
  after you tap Join (until the relay's roster confirms), and Invite (mods only) +
  Leave once you're in. Invite is gated on moderate rights.

Caveat: private groups may hide 39002 from non-members, so state resolves after
the roster is visible; a targeted 9000/9001 subscription could tighten that later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:31:57 +00:00
Claude e8a2f0cdac feat(nip29): two-pane Messages parity for relay-group views
Bring the tablet/wide two-pane Messages layout to parity with single-pane: the
view-mode toggle and the GROUPED relay rows now render above the chatroom list in
the first pane (inline group rows already flowed through the shared feed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:13:54 +00:00
Claude 5f677280d8 feat(nip29): join / leave / create / invite actions
Wire the NIP-29 membership + admin flows into the UI, all published only to the
group's host relay:

- Account: joinRelayGroup (9021 + follow), leaveRelayGroup (9022 + unfollow),
  createRelayGroup (9007 + 9002, returns the new GroupId), createRelayGroupInvite
  (9009); AccountViewModel wrappers.
- RelayGroupTopBar: a Join button when not a member; once joined, an overflow
  with Invite + Leave. Membership is read from the kind-10009 list.
- CreateRelayGroupDialog: name/topic/private/invite-only; mints a random group id,
  publishes, and navigates into the new channel. Reachable from a FAB on the
  relay channel-list screen.
- InviteRelayGroupDialog: mints a kind-9009 invite code on open and offers copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:12:11 +00:00
Claude d5d4125978 feat(nip29): grouped view mode + view-mode toggle & persistence (C, part 4)
Completes the Messages-tab integration and makes the view mode user-switchable.

- RelayGroupServerList: the GROUPED mode's relay rows (one per host relay of the
  user's joined groups), tap opens the relay's channel list; rendered above the
  DM feed in MessagesSinglePane only in GROUPED mode (MessagesPager gains a
  modifier param so it weights correctly under the section).
- RelayGroupViewModeToggle: a segmented Inline/By-relay control, shown once the
  user has joined a group; flips AccountSettings.relayGroupViewMode.
- Persistence: the view mode is saved/restored via LocalPreferences (mirrors
  defaultRelayAuthPolicy), with an updateRelayGroupViewMode setter that saves.

The full NIP-29 relay-groups feature is now navigable end-to-end: browse a
relay's channels, chat in a group, and see joined groups in the Messages tab in
either inline (default) or grouped view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 21:59:08 +00:00
Claude 34c1e4b6bb feat(nip29): inline group rows in Messages list + view-mode setting (C, part 3)
The default "inline" Messages view: joined NIP-29 channels appear as rows in the
Messages list, each with a tappable chip naming its host relay.

- RelayGroupViewMode setting (INLINE default / GROUPED) on AccountSettings.
- ChatroomListKnownFeedFilter includes joined relay-group channels' latest
  messages in the flat feed when the mode is INLINE (excluded in GROUPED, where
  they'll be reached via relay rows).
- ChatroomHeaderCompose resolves a relay-group row via the note's channel
  gatherer (like Marmot groups) and renders RelayGroupRoomCompose: name + a
  relay chip. Row tap opens the chat (Route.RelayGroup); chip tap opens that
  relay's channel list (Route.RelayGroupServer).

Remaining: the Settings toggle + persistence for the view mode, and the GROUPED
mode's relay rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 21:51:52 +00:00
Claude b03b335466 feat(nip29): relay channel-directory screen + subscription (C, part 2)
The shared backbone both Messages-tab view modes need: browse every channel a
relay hosts.

- RelayGroupDirectoryFilterAssembler + subscription: streams a relay's kind
  39000-39003 directory (registered in RelaySubscriptionsCoordinator), consumed
  into per-group RelayGroupChannels.
- LocalCache.getRelayGroupChannelsOnRelay + AccountViewModel accessor enumerate
  a relay's channels.
- RelayGroupChannelListScreen lists them (name + topic), tap opens the chat;
  Route.RelayGroupServer + AppNavigation registration.

Next: the view-mode setting (default inline) and the Messages-tab entries
(grouped relay rows vs inline channel rows with a relay chip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 21:43:12 +00:00
Claude 154264f149 feat(nip29): relay-group chat screen + navigation (C, part 1)
An end-to-end, reachable NIP-29 group chat screen, reusing the shared channel
feed/composer/subscription stack:

- RelayGroupChatScreen + RelayGroupChannelView + LoadRelayGroupChannel +
  RelayGroupTopBar (mirrors the ephemeral-chat screen), driving the generic
  ChannelFeedViewModel / ChannelNewMessageViewModel / ChannelFilterAssembler-
  Subscription against a RelayGroupChannel.
- ChannelNewMessageViewModel: RelayGroupChannel branch builds a kind-9 ChatEvent
  scoped with the `h` tag, published only to the group's host relay.
- AccountViewModel: get/checkGetOrCreate RelayGroupChannel by GroupId.
- Navigation: Route.RelayGroup, routeFor(RelayGroupChannel)/routeFor(GroupId),
  and the AppNavigation registration.

Opening a group (relay + id) now streams its live timeline and sends messages.
Remaining C: the Messages-tab entries listing chat relays and their channels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:53:32 +00:00
Claude 8d5f48fbc0 feat(nip29): account-level relay-group list state (B)
The user's joined NIP-29 groups (kind 10009, NIP-51 simple-groups), mirroring
the ephemeral-chat list infrastructure:

- commons: RelayGroupListState over SimpleGroupListEvent — liveRelayGroupList
  (Set<GroupTag>), liveRelayGroupServers (distinct host relays for the Messages-
  tab rail), follow()/unfollow() a RelayGroupChannel; joined groups stored as
  NIP-44 private items. RelayGroupListDecryptionCache decrypts once.
- AccountSettings implements RelayGroupRepository (backupRelayGroupList + get/
  update); guards on null only, since private items live in encrypted content.
- Account instantiates the state and exposes follow/unfollow(RelayGroupChannel).
- LocalPreferences persists/restores the kind 10009 backup across restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:45:35 +00:00
Claude 72755fd520 feat(nip29): subscribe relay-group channels (A)
Wire RelayGroupChannel into the shared channel subscription orchestration so an
open group streams live:

- filterMetadataToRelayGroup: relay-signed directory (39000-39003) by #d, pinned
  to the host relay.
- ChannelPublicFilterSubAssembler: RelayGroupChannel branch = messages + metadata.
- ChannelFromUserFilterSubAssembler: RelayGroupChannel branch = my messages
  (optimistic-send reconciliation).

The EOSE/subscription lifecycle (PerUniqueIdEoseManager, ChannelQueryState) is
already generic over Channel, so registering a ChannelQueryState for a group is
all a screen needs to go live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:37:45 +00:00
Claude 32e8a8a7ed feat(quartz): add group()/groupSet() tag helpers for kind 10009
TagArray.groups()/groupSet() parse the NIP-51 simple-groups list `["group", id,
relay, name?]` items, mirroring ephemChat's rooms()/roomSet(). Needed for the
upcoming relay-group list state (the user's joined groups + servers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:29:30 +00:00
Claude 5ce9eeb77d feat(nip29): wire relay groups into LocalCache
Populate RelayGroupChannel from incoming events, mirroring the ephemeral-chat
consume path:

- relayGroupChannels cache keyed by GroupId + getOrCreate/getIfExists.
- consume(GroupMetadataEvent): store the kind 39000 addressable note and update
  the channel's name/picture/about/flags. Keyed by (serving relay + group id):
  NIP-29 events don't carry their host relay, so the channel is associated only
  when the serving relay (provenance) is known.
- attachToRelayGroupIfScoped(): route kind-9 chat + kind-1068 poll events that
  carry an `h` tag into their group's channel timeline, on top of normal consume.
- Dispatch GroupMetadataEvent and the group-aware ChatEvent/PollEvent branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:26:51 +00:00
Claude dc4fe7500f feat(nip29): add RelayGroupChannel model + group message datasource
First feature-layer slice for NIP-29 relay groups, cloning the ephemeral-chat
(NIP-C7) pattern and reusing the existing relay-pinned send/subscribe
primitives (RelayBasedFilter + signAndSendPrivatelyOrBroadcast) — no new
transport.

- quartz: GroupId(id, relayUrl) identifier for a relay group (host relay +
  group id), mirroring ephemChat's RoomId.
- commons: RelayGroupChannel — a metadata-backed Channel (like NIP-28's
  PublicChatChannel) keyed by GroupId, deriving name/picture/about/flags from
  the relay-signed kind 39000 event and pinning relays() to the single host.
- amethyst: filterMessagesToRelayGroup / filterMyMessagesToRelayGroup
  datasource sub-assemblers — kind 9 + poll timeline scoped by the `h` tag and
  pinned to the host relay via RelayBasedFilter, mirroring the ephemeral-chat
  sub-assemblers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:05:35 +00:00
Claude 2620ce9a50 feat(quartz): make NIP-29 event layer a verified superset of Armada
Audited Quartz's nip29RelayGroups package against every event and tag
Armada (gitlab.com/soapbox-pub/armada) creates and reads, and closed the
gaps so Quartz can round-trip all of them.

- GroupMetadataEvent (39000): fix flag parsing. `private`/`restricted`/
  `hidden`/`closed`/`livekit` are single-element presence tags, but the old
  accessors used hasTagWithContent (requires a value) AND conflated
  restricted<->closed and hidden<->private with wrong defaults (an open
  public group parsed as private+restricted). Now each flag is a plain
  presence check. Add hasLivekit() and supportedKinds(), extend GroupStatus
  with RESTRICTED/HIDDEN/LIVEKIT, and support supported_kinds in build().
- Add GroupParticipantsEvent (kind 39004, LiveKit AV presence) and register
  it in EventFactory + KindNames.
- Add TagArray.hasTagName() presence helper in nip01Core.
- Add GroupScope.kt: hTag() builder + Event.groupId()/isGroupScoped()
  readers so kind 9 (chat), 11 (thread), 7 (reaction), 1068, 1111 etc. can
  be group-scoped via the `h` tag and read back — NIP-29 reuses these
  carriers rather than defining new content kinds.
- Add Nip29ArmadaInteropTest covering parse + build for every event with
  Armada's exact tag shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 19:45:26 +00:00
Claude da2f2ad5ab docs: deep study of Armada (NIP-29) + Amethyst integration plan
Study of Soapbox's Armada NIP-29 client — event creation, chat
management, display, invites/join/leave, relay-scoping discipline —
and a concrete plan for bringing NIP-29 relay-based groups into
Amethyst, mapping onto the existing nip29RelayGroups/nip43RelayMembers
Quartz packages and the ephemChat (relay-scoped chat) UX analog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 19:23:40 +00:00
Vitor PamplonaandGitHub b5d5fc2d2a Merge pull request #3489 from vitorpamplona/claude/amy-geode-fetch-sync-compare-0xlsl9
fix(quartz): fetchAllPages paging correctness + amy drainAllPages + SeenIds
2026-07-06 20:16:32 -04:00
Claude e8f4c5f806 refactor(cli): align fetch default limit across paths; harden paging
Audit follow-ups before merge:

- amy fetch default limit is now the same on both paths: absent --limit → 100
  for plain AND --paginate (previously --paginate silently meant "unbounded").
  `--limit 0` is the explicit opt-in to drain everything (unbounded); negative
  is rejected. The effective limit is carried on the filter so both paths agree.

- drainAllPages sizes its SeenIds for CLI-scale fetches (initialSlotsPow2 = 12,
  ~64 KB) instead of the large-walk default (~16 MB eagerly allocated per fetch);
  it grows if an unbounded drain needs it.

- fetchAllPages clamps the inclusive advance to `min(pageMinTs, boundary)` so a
  misbehaving relay that answers with an event past the requested `until` can't
  push the cursor upward — the boundary dedup and termination rely on `until`
  never increasing. No-op for honest relays (they only return events ≤ until).

Verified live: default and --paginate both cap at 100; --limit 50 → 50; --limit 0
--paginate drains the full window (>100); paging tests + SeenIds tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 23:57:40 +00:00
Claude e0ebc8fad6 feat(cli): dedup drainAllPages via SeenIds; unbounded amy fetch --paginate
Two changes to the paginated fetch path:

- Cross-relay dedup before verify. drainAllPages' single consumer now runs a
  SeenIds filter: the same widely-mirrored event arrives once per relay, and the
  repeats are dropped BEFORE the expensive Schnorr verify + store instead of
  after (they were only trimmed by FetchCommand's distinctBy). An id is marked
  seen only once it verifies, so a forged copy (valid id, bad sig) delivered
  first can't suppress the genuine one from another relay. Adds SeenIds.contains
  (peek without recording) for that check-then-add.

- `amy fetch --paginate` no longer forces a --limit. With --limit N it still
  pages up to N per relay; WITHOUT --limit it drains the whole filter unbounded
  (the filter's null limit flows straight through). Plain (non-paginate) fetch
  still trims to the default 100.

Verified live: unbounded --paginate over a ~20-min nos.lol firehose window
returns 406 (all unique, 3s) vs the old 100 cap; --limit 50 caps at 50; default
caps at 100; cross-relay fetch stays count==uniq.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 23:31:09 +00:00
Claude 338c9a41af refactor(quartz): make SeenIds single-writer, move to commonMain
Drop @Synchronized from add/reset/size: SeenIds is now documented as
single-writer (not thread-safe). Callers dedup across concurrent relay
producers by funneling events into one consumer that owns the instance — the
one-consumer ingest pattern used elsewhere — which keeps a single global set,
stays lock-free, and lets resize run without coordination.

With the JVM-only @Synchronized gone the class is pure common Kotlin
(LongArray + Hex.readLong), so it moves from the jvmAndroid source set to
commonMain and is now available on every target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 22:52:46 +00:00
Claude cb493a2291 feat(quartz): add SeenIds — a memory-lean event-id dedup filter
A run-scoped "already seen this id" filter for large, mostly-duplicate id
streams (a broad relay walk re-receiving the same event from many relays).
Keys on the first 128 bits of the id, sliced straight out of the hex with
Hex.readLong (table lookups, no parse, no allocation), in one open-addressed
LongArray — ~16 bytes/entry and the 64-char String is never retained, so tens
of millions of ids cost ~1 GB instead of a HashSet<String>'s ~6 GB. add() is
O(1) and synchronized.

Lives in the jvmAndroid source set (uses @Synchronized; a 40M-id walk is a
server-side concern). Ports the caller's implementation with the
parseUnsignedLong hot path swapped for Hex.readLong (~45-70 ns/op cheaper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 22:38:37 +00:00
Claude 5c016fc2d4 fix(quartz): fetchAllPages must not drop events at page boundaries
fetchAllPages advanced with `until = oldest - 1` (exclusive) and no dedup. That
skips any event sharing the boundary second that didn't fit in the page — which
happens at *every* page boundary landing inside a second, not just pathological
dense ones — silently dropping events. An in-process probe with no second denser
than the relay's page cap still lost one event straddling the boundary.

Page inclusively now: `until = oldest created_at of the previous page`, and drop
the re-fetched boundary events by id. The dedup set is bounded to just the current
boundary second (`until` only decreases, so duplicates can only recur there), so
memory stays O(one second), never O(total).

A single second denser than the relay's page cap can't be drained (its tail is
unreachable — no client-side fix; raising the request limit is futile since we
already send one above the relay's cap). Once a page yields nothing new we step
strictly past that second so paging keeps progressing to older events instead of
stalling forever.

Tests: boundary-straddle retrieves all 6 (was 5); dense-second-beyond-cap steps
past without stalling and still delivers the neighbours. Verified on live relays
(strfry / nostr-rs-relay / khatru): ground-truthing each dense internal second
against the paginated set shows no gaps, incl. a 36-event second fully retrieved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 21:53:48 +00:00
Claude aa8412630e refactor(quartz): collapse fetchAllPages to a single active-filter list
The search-single-page logic left two lists with different roles: the listener
counted matches over the full `pagedFilters` (including a search filter already
dropped from paging) while the subscription only sent `remainingFilters`. That
worked — the dropped filter's count was unused and `advancesCursor` kept its
hits off the cursor — but it read as if a non-subscribed filter still mattered.

Collapse to one `activeFilters` list (index + filter) that is both what we
subscribe and what the listener iterates, so counting can't drift from what was
asked. Behavior is identical; the multi-filter and search tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 20:42:34 +00:00
Claude 590731f356 feat(cli): add Context.drainAllPages + shared fetchAllPagesFromPool accessory
Amy's one-shot queries all go through Context.drain, a single REQ drained to
EOSE — so a relay that caps its REQ response (strfry's per-REQ limit, ~500)
silently truncates the result with no way to page past it.

Extract the per-relay fetchAllPages fan-out that already lived privately in
EventSync into a reusable quartz accessory, fetchAllPagesFromPool: a
sliding-window pool (maxConcurrentRelays) that paginates each relay on its own
`until` cursor, tags every event with its source relay, and does not dedup
across relays. EventSync now delegates to it (its private downloadPool/
downloadFromRelay are deleted — no behavior change: perRelayFilters is already
ordered by and complete over the relay list).

Add Context.drainAllPages, the paged sibling of drain: same verify+store and
per-relay tagging, but fully draining sets larger than one REQ. Wire it into
`amy fetch` behind --paginate/--all (filter mode only), pushing the limit into
the filter so paging stays bounded. sync (NIP-77) and fetch stay separate
interfaces.

Tests: fetchAllPagesFromPool fan-out/tagging/no-cross-relay-dedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 20:20:57 +00:00
Claude 54c8cefe69 fix(quartz): don't time-walk a search filter in fetchAllPages
NIP-50 search results are ranked by relevance, not created_at, so paging a
search filter by an `until` cursor silently degrades a top-N search into a
full time-walk of the corpus — and never terminates against a relay that
runs FTS over its whole corpus regardless of `until`.

fetchAllPages now queries a `search` filter on its first page only: it is
dropped from every later page and its hits neither advance nor drag back the
`until` cursor that co-resident non-search filters page with. onNewPage also
moves below the empty-page break so it never announces a page that isn't
fetched. Adds a test proving a search filter returns a single relay page
while a plain filter over the same capped relay still pages through the set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 19:58:11 +00:00
Vitor PamplonaandGitHub 4828ba70f4 Merge pull request #3488 from vitorpamplona/claude/hex-bit-slice-methods-ra35l0
Add efficient hex-to-Long conversion utilities for Nostr IDs
2026-07-06 14:30:17 -04:00
Claude e40d8df4d0 feat: add fast hex-to-Long slicing to Hex
Add Hex.toLong64/toLong128/toLong256 (plus the shared readLong helper) to
pack the first 64, 128 or 256 bits of a hex string into a single Long, two
Longs or four Longs. Big-endian, allocation-light, branch-free — 16 table
lookups and shifts per word. Useful as cheap map/set keys or bucket hashes
for 32-byte event ids and pubkeys without decoding to a ByteArray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CU1wR6NvQmdmNNsPe9GuN
2026-07-06 18:21:21 +00:00
Vitor PamplonaandGitHub c509014cf6 Merge pull request #3486 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-06 12:09:36 -04:00
vitorpamplonaandgithub-actions[bot] 4eba1b3e32 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-06 15:13:00 +00:00
Vitor PamplonaandGitHub ec4e5324ba Merge pull request #3485 from vitorpamplona/claude/relay-settings-parity-d5ocwr
cli: full relay-settings parity for `amy relay` (noun-first, all list kinds)
2026-07-06 11:10:30 -04:00
Claude 5aae4368da refactor(cli): noun-first amy relay, outbox/inbox NIP-65 facets
Restructure `amy relay` from verb-first `relay add URL --type T` to noun-first
`relay <noun> <verb>`, matching amy's `marmot group …` / `cashu mint …`
convention. The relay-list type is now a required path segment (no implicit
default), and a bare noun lists that bucket.

NIP-65 (kind:10002) is fronted by two facet-nouns, `outbox` (write) and `inbox`
(read), replacing the `--marker` flag. They edit the single 10002 event and
apply the spec's merge rules:
- outbox add R on a read-only R  → both
- inbox  add R on a write-only R → both
- outbox remove R on a both-R    → read  (stays in inbox)
- inbox  remove R on a both-R    → write (stays in outbox)
- dropping the last facet removes R entirely
`relay nip65` shows the combined view; `nip65 remove`/`clear` edit the whole
event.

Other buckets are noun+verb: `relay dm|key-package|search|private|blocked|
trusted|proxy|indexer|broadcast|feeds <add|remove|set|clear|list>`. `set` needs
≥1 URL; `clear` empties. `relay add|remove URL` (no noun) stays as the
transport fan-out (nip65 both + dm + key-package).

BREAKING (cli --json/args): removes `relay add/remove/set --type T` and
`--marker`; `relay list` overview now keys nip65 as `outbox`/`inbox`/`nip65`
and the DM bucket as `dm` (was `inbox`). In-repo harnesses updated
(cache/dm/marmot setup drop `--type all`; cache T5 asserts `.dm`).

Verified end-to-end: merge semantics, encrypted NIP-51 round-trips, facet
set/clear, fan-out, aliases, and error paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 15:02:28 +00:00
Vitor PamplonaandGitHub b4c18d5efb Merge pull request #3482 from davotoula/feat/ps1-save-kind-38192
render PS1 memory-card saves over nostr (kind 38192)
2026-07-06 10:43:46 -04:00
Claude c7bde868e1 feat(cli): require --clear to empty a relay bucket
`relay set --type T` with no URLs is now rejected (bad_args, exit 2) instead
of silently wiping the list — a bare empty `set` is almost always a shell
variable that expanded to nothing. Emptying a bucket is explicit: pass
`--clear` (mutually exclusive with URLs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 14:30:08 +00:00