Commit Graph
16333 Commits
Author SHA1 Message Date
Claude 2ffd64c170 refactor: rename relay-group REQ assemblers by when they run
The assembler names didn't say when each is active — most confusingly, a
"Threads" assembler with no matching "chat" one, because group chat (kind-9) is
served by the shared `channel` assembler, not a group-specific one. Rename the
four group-specific families for their surface, and document that chat has no
dedicated assembler:

  relayGroupDirectory  -> relayGroupsOnRelay        (browsing one relay's channels)
  relayGroupRoster     -> relayGroupMyJoinedGroups  (metadata+rosters of joined groups)
  relayGroupThreads    -> relayGroupThreadFeed      (a group's forum-threads tab)
  relayGroupPreview    -> relayGroupWarmup          (prefetch before a group opens)

Each family's FilterAssembler / QueryState / SubAssembler / Subscription + file
renamed to match. relayGroupsDiscovery is left as-is: it already names the
Discover feed and shares its token namespace with the screen/DAL/settings, so a
rename would either collide with RelayGroupDiscoveryFeedFilter or corrupt those.
Pure rename; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 20:05:43 +00:00
Claude 23c93e43f9 feat: gate the group chat composer on membership
NIP-29 relays reject kind-9 writes from non-members, so typing in a group you
haven't joined only earns a silent relay rejection. Show the composer only when
the relay-signed roster (39001/39002) lists me as a member/mod/admin — the same
boundary the threads FAB already uses — collecting the channel metadata flow so
it appears the instant my join is accepted. Otherwise replace it with a notice:
"Join this group to send messages" (open) or an invite-only explanation (closed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 19:14:14 +00:00
Claude 525a615b51 fix: circular FAB on the relay group list + threads screens
The "groups on this relay" and group-threads screens used a bare
FloatingActionButton, which renders as Material 3's default rounded-square shape.
Every other new-post FAB in the app is circular (shape = CircleShape); match it
so the group FABs read the same as the rest of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 19:08:42 +00:00
Claude 2527364a61 fix: load the kind-10009 groups list at login (consume + REQ)
The user's NIP-51 "simple groups" list (kind 10009 — joined NIP-29 groups +
servers) was neither consumed nor requested at login:

- LocalCache had no dispatch branch for SimpleGroupListEvent, so an arriving
  10009 fell through to the "Event Not Supported" else and was dropped —
  RelayGroupListState, which reads it from the addressable cache, could never
  populate from the network (only from the on-device offline backup).
- The account-info assemblers (filterAccountInfoAndListsFromKey /
  filterBasicAccountInfoFromKeys) never REQd kind 10009 alongside the sibling
  NIP-51 lists, so a fresh sign-in never fetched it.

Add the consume branch (consumeBaseReplaceable, like every sibling list) and
include SimpleGroupListEvent.KIND in both assemblers, so "My Groups" and group
memberships resolve from the start of login without opening the groups screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 19:08:27 +00:00
Claude 4fca9ae25e feat: message-count + follows-in-group on discovery cards; split relay chip
Three group-discovery card changes:

- Reactive loaded-message count. The preview subscription streams the group's
  recent kind-9 chats into the channel note cache; the card now shows that count
  on the stats line ("12 members · 50+ messages"), updating live as messages
  arrive. Bumped the preview page 15 -> 50 so an active chat reads as "50+"
  (also a better warm-up); the display caps at DISCOVERY_MESSAGE_CAP.
- People-you-follow social proof. RelayGroupChannel.participatingFollows()
  intersects the relay-signed roster with the kind-3 follow set; the card shows
  an overlapping face pile + "%d people you follow" caption when non-empty.
- Split the relay chip's tap targets. Tapping the chip body now opens that
  relay's full group list (Route.RelayGroupServer); only the star toggles the
  relay favorite. Previously the whole chip favorited, which was easy to hit by
  accident.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 18:24:36 +00:00
Claude c253a5aa12 feat: relay autocomplete on the Find Groups browse field
The browse-a-relay field was a plain text box. Wire it to the same relay
autocomplete every other relay field uses (RelaySuggestionState +
ShowRelaySuggestionList over LocalCache.relayHints): as you type, a popup lists
matching known relays, and tapping one opens that relay's group directory
directly. The manual paste-and-Go path and the your-relays / popular sections
are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 17:45:55 +00:00
Claude a545151a34 fix: reject non-32-byte pubkeys when decoding npub/nprofile
A Nostr pubkey is x-only, exactly 32 bytes, but NPub.parse/NProfile.parse never
checked the length — they hex-encoded whatever bytes the bech32/TLV carried. A
malformed npub/nprofile that some clients encode with the full 33-byte COMPRESSED
secp256k1 key (0x02/0x03 prefix) therefore round-tripped its 66-char hex straight
into a `p`/`q` tag via the quote/mention path, and a strict relay (relay29 /
pyramid.fiatjaf.com) rejected the whole group message:

  blocked: schema validation failed: tag[..]: invalid pubkey value
  '02977dcf…c3402' ... pubkey should be 64-char hex

We never generate compressed keys ourselves (Nip01Crypto.pubKeyCreate strips the
prefix byte); this is purely inbound malformed input. Enforce the 32-byte length
at the decode boundary so the bad entity never becomes a mention/quote tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 17:24:03 +00:00
Claude cc048c7ad1 feat: show the host relay as a starred chip on group discovery cards
The standalone star icon sat right next to the group's Join button, so it read
as a second way to act on the group when it actually favorites the host RELAY.
Pull the relay out of the member-count line into its own tappable chip with the
star inside it — favorited relays fill primary, unfavorited show a tonal outline
— so the two scopes are visually distinct: the chip favorites the relay (and
surfaces its groups under the relay filter), the button joins the group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 16:42:40 +00:00
Claude 6cca872f80 fix: refresh the groups feed when the resolved relay set catches up
Flipping the top-nav filter A->B showed A's groups until a manual pull-to-
refresh. The selected list flips synchronously, but the per-relay set the feed
filters on (liveRelayGroupsDiscoveryFollowListsPerRelay) resolves a frame later
via the async outbox loader. feedKey() keyed only on the list code, so the first
refresh ran against the stale (A) set and the catch-up emission — same list code
— was swallowed by checkKeysInvalidateDataAndSendToTop's key-unchanged guard,
freezing the feed on A.

Fold the resolved discriminator into feedKey(): the joined ids for "My Groups",
the per-relay constraints otherwise (both content-hashed via data classes). The
key now moves when the resolution lands, so the refresh fires and the feed
follows the selection without a manual pull.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 16:38:17 +00:00
Claude 3529a71052 fix: send a discovery REQ when a relay chip is selected
The discovery filter spinner offers a favorite-relay chip (TopFilter.Relay),
but makeRelayGroupsDiscoveryFilter had no branch for RelayTopNavPerRelayFilterSet
— it fell through to `else -> emptyList()`, so selecting a relay sent no REQ and
the feed stayed empty even though the dal's toGroupConstraints() already mapped
that filter to AllGroups-on-that-relay. Add filterRelayGroupsByRelay (the same
whole-directory pull as Global, scoped to the one relay) and wire the dispatch
branch, restoring parity between the two per-type tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 16:22:36 +00:00
Claude 4265d7a00b feat: consume the remaining NIP-29 event kinds in LocalCache
Only 39000/39001/39002 (metadata + rosters) and the group-scoped content
kinds were consumed; every other registered NIP-29 kind deserialized fine but
fell through to the "Event Not Supported" else branch and was dropped.

Add explicit branches for the whole family:
 - 39003 SupportedRoles and 39004 GroupParticipants are relay-signed
   addressables — durable group state alongside 39000/1/2 — so they're stored
   replaceably (consumeBaseReplaceable).
 - the 9xxx moderation actions (put/remove user, edit/delete metadata, create/
   delete group, create invite) and 9021/9022 join/leave requests are regular
   one-shot events the relay is authoritative for; store them via
   consumeRegularEvent so they're queryable and no longer warn, without acting
   on them client-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 15:30:51 +00:00
Claude a4679da0a9 refactor: move GroupDiscoveryConstraint matcher to commons
The sealed GroupDiscoveryConstraint matcher (AllGroups/ByPeople/ByHashtags/
ByGeohashes/AnyOf) is pure over RelayGroupChannel + HexKey with no LocalCache,
eose-manager, or topNavFeeds dependency, so it belongs in :commons alongside
RelayGroupChannel where Desktop/CLI can reuse it. The amethyst dal keeps only
the platform-specific toGroupConstraints() mapping from the Android top-nav
filter set onto the shared matcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 15:25:03 +00:00
Claude ec3eff83d8 fix: "My Groups" shows joined list ∪ roster memberships
"Mine" only listed groups where the relay-signed roster (39001/39002) already
had me as an admin/member — so a group I just joined (or an open group I post
in without being rostered) wouldn't appear until the relay caught up. It now
shows the UNION of:
 - my kind-10009 joined list (authoritative, immediate), and
 - groups whose roster lists me as an admin/member.

The screen re-scans when the joined list changes (join/leave), so a newly
joined group appears right away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:58:49 +00:00
Claude a44007bb95 fix: order the "Mine" chip last in the Relay Groups filter, like other feeds
The joined-groups ("Mine") entry was placed first in the discovery filter
dropdown; every other feed lists it last in the base group (after Global).
Match that ordering so the top-nav popup is consistent across screens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:54:23 +00:00
Claude 34bbb7b727 fix: robohash crash on short ids + standard Relay Groups top bar/layout
- RobohashAssembler read up to hash[10] but only required the input to be
  >10 chars; a 16-char NIP-29 group id is valid hex that decodes to 8 bytes,
  so hash[8] threw ArrayIndexOutOfBoundsException and crashed the discovery
  feed. Require >=22 hex chars (>=11 bytes) before decoding; anything shorter
  falls back to sha256 (32 bytes). Latent crash for any short hex seed.
- The discovery screen used a hand-rolled ShorterTopAppBar (dropping the
  standard search icon + memory chip) to fit a browse action. Switched to the
  shared UserDrawerSearchTopBar like every other top-level feed; the
  browse-a-relay action moved to the FAB (DisappearingScaffold.floatingButton).
- Removed the extra Column(Modifier.padding(padding)) wrapper that double-
  applied the top-bar inset (rememberFeedContentPadding already accounts for
  it) — that was the large blank block above the first card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:47:04 +00:00
Claude c97e697ef0 refactor: unify Relay Groups onto the discovery feed
The top-level "Relay Groups" tab was a thin server-list home screen while a
separate "Find Groups" discovery feed did the real work. They're now one screen:
the discovery feed IS the Relay Groups tab, defaulting to a "My Groups" filter.

- Route.RelayGroups now renders the discovery feed (top-level DisappearingScaffold
  + AppBottomBar + drawer top bar with the filter spinner and browse action).
- "My Groups" (TopFilter.Mine) lists the groups you've joined. These live on their
  host relays (kind 10009), not your outbox, so the filter scans the cache for
  groups where you're the relay-key / an admin / a member; the joined rosters are
  kept live by RelayGroupRosterSubscription mounted on the screen.
- Per-relay "server" browsing is still available via the relay chips in the filter;
  the grouped server rail still shows in the Messages tab (GROUPED mode).
- Default discovery filter is now Mine (was Global); the "Mine" chip is back in the
  route list.
- Deleted RelayGroupsHomeScreen and the redundant Route.RelayGroupDiscovery; the
  Messages "Find groups" row and everything else point at Route.RelayGroups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:31:02 +00:00
Claude 6a663781de fix: address relay-group audit findings (correctness, perf, consistency)
Correctness:
- Discovery sort no longer reads createdAt live in the comparator (TimSort
  "contract violated" crash risk); orders by member count then the shared
  sortedByDefaultFeedOrder snapshot.
- One shared resolver (relayGroupDiscoveryChannelFor) is used by the feed
  match, sort AND the row, so a 39000 seen on >1 relay always binds one
  channel — no more "sorted by relay B, rendered with relay A's empty roster".
- Roster (39001/39002) arrivals now re-inject the group's 39000 into the feed
  and re-invalidate the datasource, so a group where a follow is an admin/
  member surfaces instead of staying hidden / frozen at 0 members.
- Group replies route to the group's host (resolved from the channel), never
  falling through to signAndComputeBroadcast — fixes the outbox leak when the
  parent note had no relay provenance.
- Messages list (inline mode) updates group rows incrementally: the additive
  path now handles group-scoped messages, not just public/ephemeral/DM.
- Optimistic null-relay group sends attach only to an unambiguous single
  channel, so a message to the "_" group on relay A no longer bleeds into "_"
  on relay B.

Consistency:
- AllFollows discovery also REQs #t/#g (not just authors), matching the local
  AnyOf constraint; muted-authors maps to ByPeople; community stays AllGroups
  — fetch and display now agree.
- Global drops the 1-week `since` floor so long-lived group metadata is fetched.

Performance:
- #d metadata backfill scans the group cache once per filter assembly (grouped
  by relay), not once per relay.
- Discovery rows warm content only; the directory subscription already streams
  metadata/rosters for the relay.
- memberCount is memoized (members ∪ admins recomputed on roster change, not per
  read); thread re-sort extracted.
- Discovery list gets rememberFeedContentPadding, contentType and animateItem.

CLI:
- `relaygroup edit` preserves current name/about/tags when only a flag changes.

Cleanup: dropped dead relayKeys() branches and the redundant AnyOf single-lens
collapse in the discovery constraint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 23:48:49 +00:00
Claude 5c39b56ce6 feat: warm visible discovery groups + elevate the discovery card
Two gaps in the discovery feed's rendering:

- Preload: each on-screen group row now mounts RelayGroupPreviewSubscription,
  so the newest ~15 chat messages / threads are prefetched to the group's host
  relay while the card is visible — tapping a group opens an already-populated
  screen instead of loading from scratch. Lifecycle-aware and bounded to the
  rows the LazyColumn actually composes; tears down as they scroll off. Same
  warm-up the inline group-link card already used.

- Visuals: the flat list row is now an ElevatedCard matching the inline card —
  primary-ring avatar, member-count icon, status pill, description — while
  keeping the discovery-only actions (favorite-relay star + Join). Rows stay
  reactive: they observe the channel metadata flow, so name/picture/members/
  membership fill in and update live without moving the layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 23:10:35 +00:00
Claude 7f10ff6fae fix: narrow relay-group follows/authors REQ instead of broad directory pull
A follows/authors filter no longer pulls every group on the relay and filters
client-side. It now emits, per relay, the three narrowed REQs a relay-signed
group is discoverable by:
  1. {kinds:[39000], authors:<follows>}          — the relay signing-key is a follow
  2. {kinds:[39001,39002], #p:<follows>}          — a follow is an admin/member
  3. {kinds:[39000], #d:<roster group-ids>}       — metadata backfill for (2)

(3) reads the group-ids of cached 39001/39002 rosters that already mention a
follow (empty on the first pass, filled once (2)'s events land and the
sub-assembler re-invalidates), since a #p roster hit doesn't carry the 39000.

ByFollows routes through the shared per-relay author builder (mirrors Git);
muted-authors likewise. Global / communities keep the broad directory pull,
since they carry no author dimension to narrow on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 22:41:26 +00:00
Claude dc5bf6499a refactor: rebuild relay-group discovery on the canonical feed stack
Restructures the discovery feed to match the gitRepositories package exactly —
the shape every feed screen in the app uses — replacing the bespoke ViewModel +
per-relay directory fan-out.

New package layout (mirrors gitRepositories/):
- dal/RelayGroupDiscoveryFeedFilter — AdditiveFeedFilter<Note> over the 39000
  addressables; GroupDiscoveryConstraint (moved to its own file) supplies the
  relay-signed match (relay-key / admin / member, or #t/#g tag).
- datasource/RelayGroupsDiscoveryFilter — makeRelayGroupsDiscoveryFilter per-type
  dispatch over IFeedTopNavPerRelayFilterSet.
- datasource/subassemblies/ — FilterRelayGroupsGlobal / ByFollows / ByAuthors
  (+ muted) / ByHashtag / ByGeohashes / ByCommunity, plus the shared directory
  builder. Authors can't be a REQ constraint (a 39000 is relay-signed), so the
  people filters pull the directory per relay and the dal narrows locally; only
  topic/geo carry a relay-side #t/#g constraint.
- datasource/RelayGroupsDiscoveryFilterAssembler + SubAssembler
  (PerUserAndFollowListEoseManager) + FilterAssemblerSubscription.

Wiring, parallel to gitRepositories:
- AccountFeedContentStates.relayGroupsDiscoveryFeed (+ update/delete/trim fan-out)
- TopNavFilterState.relayGroupsDiscoveryRoutes
- RelaySubscriptionsCoordinator.relayGroupsDiscovery
- ScrollStateKeys.RELAY_GROUPS_DISCOVERY_SCREEN

Screen now runs on FeedContentState + RefresheableBox + RenderFeedContentState
(custom onLoaded rendering the joinable group cards). Old ViewModel deleted; the
constraint additions to the directory assembler reverted (browse-a-relay path
keeps the plain broad directory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 22:17:50 +00:00
Claude 0140b837b1 feat: per-type relay group discovery (follows/admins/members + topics/geo)
Relay-signed kind-39000 has no author-of-a-follow, but the people dimension
still exists: a follow may be the relay signing key, a group admin (39001), or
a member (39002). Discovery now resolves each top-nav filter into a per-relay
GroupDiscoveryConstraint instead of collapsing every filter to the same REQ.

quartz:
- GroupMetadataEvent / EditMetadataEvent: build + read #t (topics) and #g
  (geohash, mip-mapped so a coarser followed geohash still matches). Interop
  tests for parse/build round-trips.

amethyst:
- dal/RelayGroupDiscoveryFeedFilter: sealed GroupDiscoveryConstraint
  (AllGroups / ByPeople / ByHashtags / ByGeohashes / AnyOf) + toGroupConstraints()
  mapping each IFeedTopNavPerRelayFilterSet to per-relay constraints, with
  matches() covering the relay-key/admin/member people paths and topic/geo tags.
  Unit tests.
- Directory REQ narrows to 39000 #t/#g for topic/geo filters, broad directory
  otherwise (people match needs the rosters).
- ViewModel keys the feed on the constraint map and re-scans on any directory
  event (metadata OR roster) so late-arriving admins/members surface groups.
- Create/edit form gains a Discovery section (topics + geohash) threaded through
  Account.createRelayGroup/editRelayGroupMetadata and EditMetadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 21:42:38 +00:00
Claude 1b1413f861 refactor(nip29): drive group discovery through the standard feed-filter pattern
Follow the Pictures/Git pattern exactly instead of the custom lean variant:

- Persist the selection in a new `defaultRelayGroupsDiscoveryFollowList` account
  setting (AccountSettings + LocalPreferences save/parse/wire + change mutator +
  Account `liveRelayGroupsDiscoveryFollowLists(PerRelay)` flow) — like every other
  feed. A missing pref key just defaults to Global, so it's additive, not a
  migration.
- Top bar uses the shared `FeedFilterSpinner` bound to that setting, so all the
  standard options apply — Global, Follows, followed hashtags/geohashes, and
  per-relay chips (a starred favorite relay shows up as a chip). No bespoke
  filter enum, no separate favorites toggle.
- Remove the 40-relay cap: fan the directory query out to every relay in the
  set, matching the other global feeds.
- ViewModel now reads `liveRelayGroupsDiscoveryFollowListsPerRelay` → relayKeys()
  (extended for the Relay chip) and lists the groups those relays host.

The ★ still stars a group's host relay (kind-10012 relay-feeds list), which is
what surfaces it as a relay chip in the filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 20:50:01 +00:00
Claude a18cad6121 feat(nip29): relay-group discovery feed with top-bar filter + favorites
A discovery feed to find and join groups, modeled on the Git/Pictures top-nav
filter. Because a group's kind-39000 is relay-signed (not author/tag scoped),
every filter option reduces to a RELAY SET: the feed queries kind 39000 on the
relays the selected filter resolves to.

- Global / Follows / Around me — resolved via the account's shared
  topNavFilterFlow + outbox loader (relayKeys() takes the per-relay set's keys).
  Driven by a ViewModel-local TopFilter, so no account-settings/LocalPreferences
  plumbing and no persistence (resets to Global per visit).
- Favorite relays (B) — a toggle backed by the kind-10012 relay-feeds list
  (account.relayFeedsList); a star on each card adds/removes the group's host
  relay via followRelayFeed/unfollowRelayFeed.
- Fans the existing per-relay RelayGroupDirectorySubscription out across the set
  (capped at 40 relays); the feed reads LocalCache.relayGroupChannels for those
  relays, re-scanning as kind-39000 events arrive.
- Joinable cards: avatar, name, member count, private/invite-only pill, Join
  (open groups join directly; closed ones open the group for the code), and the
  favorite-relay star. "Find groups" now opens discovery; the paste-a-relay
  browse is a top-bar action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 20:21:41 +00:00
Claude f3088d00c5 feat(nip29): convert group create/edit/new-thread dialogs to full screens
Content-authoring flows are screens in Amethyst, not dialogs. Promote the three
NIP-29 group dialogs to routed screens:

- RelayGroupCreateScreen / RelayGroupEditScreen — a shared metadata form backed
  by RelayGroupMetadataViewModel with a tap-to-upload avatar hero (gallery pick →
  compress → NIP-96/Blossom upload, mirroring the emoji-pack editor). Create now
  exposes EVERY group parameter: name, description, picture, and all four status
  flags (private, invite-only, members-only posting, unlisted), each with a
  one-line explanation. Edit prefills reactively from the metadata flow and won't
  clobber in-progress edits.
- RelayGroupNewThreadScreen — full-screen title+body composer with rememberSaveable
  state so a half-written thread survives rotation / process death.

Plumbing: Account + AccountViewModel createRelayGroup/editRelayGroupMetadata gain
picture + isHidden + isRestricted (the quartz 9002 builder already supported them).
Routes RelayGroupNewThread/Create/Edit registered; the Threads FAB, channel-list
FAB, and topbar Edit menu now navigate to them; the three dialog files are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 19:40:17 +00:00
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