Skip to content

refactor(communities): extract the join owner and add the pre-join relay lookup - #1815

Merged
matt2e merged 3 commits into
mainfrom
onboarding/02-communities-join-owner-and-lookup
Oct 29, 2026
Merged

matt2e merged 3 commits into
mainfrom
onboarding/02-communities-join-owner-and-lookup

Conversation

@matt2e

@matt2e matt2e commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Onboarding stack (merge in order, top to bottom):

  1. fix(navigation): run the visit deadline only while a presenter is attached #1814 navigation: visit deadline only while a presenter is attached
  2. refactor(communities): extract the join owner and add the pre-join relay lookup #1815 communities: join owner and pre-join relay lookup ← this PR
  3. feat(identity): observable nsec backup status with pass tickets, explicit lock release, and refused-recovery copy #1816 identity: native nsec backup status
  4. feat(builderlab): tenant table — a login origin per hosted relay, granted natively and accepted by the binding signer #1817 builderlab: tenant table and granted native origins
  5. feat(identity): first-run Builderlab account client — sign-in without a plugin session, kgoose reads, link/unlink, backup, saved communities — and its dev seed #1818 identity: first-run Builderlab account client and dev seed
  6. feat(identity): first-run setup store, step table and pages (composition follows in the next PR) #1819 identity: first-run setup store, step table and pages
  7. feat(identity): the first-run Welcome flow replaces the key-setup gate (FOUNDATION: App.tsx) #1820 identity: Welcome flow replaces the key-setup gate
  8. feat(settings): read-only Key backup row in Settings → Identity #1821 settings: read-only Key backup row
  9. [DO NOT MERGE] chore(identity): DO NOT MERGE — isolated debug Keychain slot (full stack for native testing) #1822 DO NOT MERGE: isolated debug Keychain slot (full stack for native testing)

Depends on: nothing (based on 01 only to keep the stack linear). PRs 04–07 build on it.

Diff: production +620/−137; tests +904/−0

What this layer is

Everything the in-app Add-a-community dialog and a future first-run caller share, with no reference to first run. join.ts extracts the dialog's admission (admitCommunity: journal-before-effect, read-before-reclaim, accept-policy → claim → signed read, CLAIM_REFUSALS) and completion (completeJoin: fresh read, publish if different, readback, joined() last) verbatim; CommunityDialog.tsx shrinks to call them with native-join.test.tsx and CommunityDialog.test.tsx byte-identical to main and green. join-policy.ts is the one join-policy validator, used by native-api.ts (behaviour unchanged) and typed into api.ts. find-community.ts + FindCommunity.tsx are the unauthenticated lookup (NIP-11 GET / + GET /api/join-policy, classified errors, LoadingRegion-delayed status, the community's own consent copy, onFound/onJoin(found, consent)), plus enteredRelayOrigin() (bare host ⇒ wss://), relaySelf() shared with connectNativeTransport, JournalError, and ProfileFields' autoFocus prop.

Review focus

  • join.ts against the removed blocks of CommunityDialog.tsx: the move must be verbatim — same request order, same fences (current), same journal begin/finish points. completeJoin without publish has no caller until PR 07 (stated in the file).
  • find-community.ts: the error classification is deliberate — a policy 404 is "none" only after NIP-11 confirmed a Buzz relay; a malformed policy is retryable, never a silent consent waiver; nothing is signed.
  • FindCommunity.tsx lives in communities/, not identity/, because join.ts imports its JoinConsent type. Design: LoadingRegion, Checkbox, Button, Avatar, role classes only; the consent copy is CommunityDialog's verbatim.
  • relay/native.ts relaySelf(): connectNativeTransport now uses it for its existing 64-hex check — no behaviour change intended.

Checks run on this branch's tree

  • bin/pnpm tsc --noEmit ✓
  • biome check on the 15 changed TS/TSX files ✓
  • pnpm design:check ✓
  • bin/pnpm vitest run src/features/communities src/features/relay: 2523 passed / 10 failed / 4 skipped of 2537 (141 files). All 10 failures are 5 s timeouts and abort errors under load in four relay store files this layer does not touch (read-state, store-discovery, store-system-discovery, unread-startup); this layer's only relay change is relay/native.ts. Not rerun locally — CI is the verification.

Native evidence

None on this tree. The native first-run recordings and the hands-on checks on the branch were made on the full tree, which only the DO-NOT-MERGE PR 09 reproduces (it adds the isolated debug Keychain slot). Treat every layer below 09 as unit/integration-tested only.

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review October 28, 2026 13:40
@matt2e
matt2e requested a review from a team as a code owner October 28, 2026 13:40
@matt2e
matt2e force-pushed the onboarding/02-communities-join-owner-and-lookup branch from d47e7c2 to 2415a08 Compare October 28, 2026 14:18
@matt2e
matt2e force-pushed the onboarding/01-navigation-presenter-deadline branch from 5cc2c69 to b9ea475 Compare October 28, 2026 14:21
@matt2e
matt2e force-pushed the onboarding/02-communities-join-owner-and-lookup branch from 2415a08 to 600aa3a Compare October 28, 2026 14:21
Base automatically changed from onboarding/01-navigation-presenter-deadline to main October 28, 2026 22:02

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Three non-blocking observations on the preparatory lookup surface and its tests. No demonstrated regression in the existing join flow; native host integration remains unverified.

Comment thread src/features/communities/find-community.ts Outdated
* does, and leaving it out keeps Join a disabled placeholder. While `onJoin` is
* pending the page holds still; what its failure means is the caller's to show.
*/
export function FindCommunity({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P3] Introduce the lookup surface with its first production caller

The production caller search over src/ found no rendering caller for this component or consumer of the lookup flow; the no-publication completion branch and related preparatory APIs likewise await later stack layers. This fixes callback and lifecycle contracts before their integration exercises them, contrary to the repository requirement for a current caller. Consider moving this surface and its unused supporting branches into the layer that first consumes them, keeping this layer focused on the existing join extraction. This is a scope/maintainability concern, not a demonstrated live-path failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Keeping the lookup in this layer. The stack is reviewed as one feature, and the callers are in #1820: Welcome.tsx renders for a typed community URL and for sign-in discovery, and calls completeJoin without publish when the relay or local default already holds a profile, or on Skip. FoundCommunity is also imported by #1817 and #1819, so the lookup can't move to its rendering caller, and moving FindCommunity.tsx into #1820 would grow the largest, FOUNDATION-touching layer. (If the optional change lands:) I did remove the one branch no layer reaches: with neither onJoin nor onFound, Join was a disabled "Coming soon" placeholder; the props now require exactly one.

Comment thread src/features/communities/join.test.ts Outdated
…lay lookup

Everything the in-app Add-a-community dialog and a future first-run caller
share, with no reference to first run. Behaviour of the dialog is unchanged:
`native-join.test.tsx` and `CommunityDialog.test.tsx` are byte-identical to
main and green.

Join owner (`join.ts`, new): the dialog's two async blocks move out verbatim.
`admitCommunity({ community, journal?, code, policy?, consent, current })`
is the access step — a pending journal entry is read before it is reclaimed
(a denied read tolerated), an admitted key skips the claim, otherwise
`journal.begin` before any remote effect, then `accept-policy`
(`age_confirmed: consent.adult`) → `claim` (refusals mapped through
`claimRefusal`, anything else rethrown, a status other than
`joined`/`already_member` ⇒ "Membership was not confirmed") → the signed
profile read. `completeJoin({ community, membership, found, journal?,
entry?, publish?, communities, current })` is the profile step — fresh read,
publish only if different, readback or "Your profile change is not
current…", then `communities.joined(membership, profileDefault(…))` last and
`journal.finish`. With `publish` omitted there is no readback: the
membership is saved with the relay's profile or the local default. The
dialog never omits it; that branch's caller arrives with the Welcome flow.
`CLAIM_REFUSALS`/`claimRefusal` live here; `CommunityDialog.tsx` shrinks to
call the two functions.

Join policy (`join-policy.ts`, new): the one validator of `/api/join-policy`
answers (`JoinPolicy`, `joinPolicy()`), moved out of `native-api.ts`, whose
`info` route behaves as before; `CommunityInfo.policy` in `api.ts` names the
type.

Pre-join lookup (`find-community.ts` + `FindCommunity.tsx`, new): two
unauthenticated `relay_http` GETs in parallel, `GET /` (NIP-11) and
`GET /api/join-policy`, nothing signed. Errors are classified, never echoed:
network/timeout/5xx ⇒ "Could not reach the relay…" with Retry; non-JSON or a
non-Buzz `software`/`name` ⇒ "This address isn't a Buzz community."; a Buzz
relay without `read_state_snapshot`/`icon`/`buzz_v1` ⇒ "No community is set
up at this address."; a policy 404 means "none" only after NIP-11 confirmed
a Buzz relay, and a malformed policy is the retryable error, never a silent
consent waiver. NIP-11 `self` is copied only when it is 64 lowercase hex
(`relaySelf()` in `relay/native.ts`, now shared with
`connectNativeTransport`). The page shows the hostname and Cancel at once
with a `LoadingRegion`-delayed "Looking up {host}…" status (DESIGN.md: initial
reads stay silent for one second), then the found community — icon, mono
hostname, the community's own consent boxes ("I agree to this community’s
Terms of Service and Privacy Notice.", "I confirm that I am at least 18
years old.") with links to `{origin}/api/join-policy/terms|privacy` — and
Cancel-left/Join-right; `onFound(found)` hands a found community to the
caller instead, `onJoin(found, consent)` may return a promise and shows
"Joining…" while pending. Cancel aborts the lookup so a late answer never
lands.

Also: `enteredRelayOrigin()` in `destination.ts` prefixes `wss://` for
scheme-less human input and delegates to the strict `relayOrigin()` (saved
IDs and aliases never resolve); `JournalError` for the join journal's two
storage messages so a caller can show them as is; `ProfileFields` gains an
optional `autoFocus` (display name only).

Note for the reviewer: `FindCommunity.tsx` lives in `communities/`, not
`identity/`, because `join.ts` imports its `JoinConsent` type and the page
is the dialog's lookup made standalone.

Tests (new, under `src/features/communities`): `join.test.ts` (journal write
precedes `accept-policy` → `claim` → read with exact bodies; a throwing
`setItem` sends nothing; pending entry read before reclaim; refusal mapping;
"not confirmed"; fences; `completeJoin` publish/readback/no-publish paths),
`find-community.test.ts` (the NIP-11 and policy classification matrix,
parallel issue with the NIP-11 verdict first, `self` extraction, abort
pass-through), `FindCommunity.test.tsx` (finding → found with and without a
policy, the delayed status under fake timers, consent gating, `onFound`,
`onJoin` pending/rejected, Cancel discarding a late answer, Retry, the
Cancel-only errors), `destination.test.ts` (+33, the secure-origin rule for
entered addresses).

Stack: PR 02 of the onboarding stack, based on main; depends on nothing.
04–07 build on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… the save order

The pre-join lookup added its own 12 s `AbortSignal.timeout` on top of the
caller's signal. On the native path `nativeRelayRequest` only checks the
signal after `relay_http` returns, and IPC cannot cancel reqwest, so the
bound never shortened the wait. Instead, a relay that answered between 12 s
and the native limit (10 s connect, 30 s total) kept "Looking up…" on screen
until it answered, then had a valid answer reported as "Could not reach the
relay…". The lookup now passes the caller's signal through and relies on the
native limit; Cancel still aborts it at any time.

A new `find-community.test.ts` case drives `nativeRelayFetch` with a mocked
`relay_http` held for 20 s under a controlled `AbortSignal.timeout`: it fails
with `unreachable` before this change and finds the community after.

`join.test.ts`'s call sequence now records `communities.joined`, so the three
successful-completion tests assert the membership is saved before
`journal.finish`. Swapping those two calls in `join.ts` fails all three; it
passed before.

Addresses kalvinnchau's review comments 1 and 3 on #1815.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the onboarding/02-communities-join-owner-and-lookup branch from 600aa3a to c172562 Compare October 28, 2026 23:20

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Secondary review: the lookup deadline and membership-ordering test fixes address the earlier findings, and the shared API scope is supported by concrete stack consumers. No blocking defects found. The unused placeholder branch remains a non-blocking cleanup; native host integration remains unverified.

Comment thread src/features/communities/FindCommunity.tsx Outdated
…ndCommunity

`FindCommunity` showed a disabled "Join — Coming soon" button when a caller
passed neither `onFound` nor `onJoin`. That placeholder was only reachable
before the first-run Welcome page could join; since then every caller passes
exactly one (`Welcome.tsx`'s typed-URL page passes `onJoin`, its landing
lookup `onFound`), and only tests rendered the fallback.

The props are now a union, so passing neither or both callbacks fails
typecheck. The "Coming soon" label, the `!onJoin` guards in `disabled` and
`join()`, and the docstring's placeholder sentence are gone. No reachable
behaviour changes.

Tests: the no-policy case renders once with `onJoin` and asserts no
checkbox, no icon and an enabled Join; the `onFound` hand-over drops its
now-invalid `onJoin`; the lookup and failure cases pass `onJoin`.

Addresses kalvinnchau's review comment r4407169798 on #1815.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit fcbb834 into main Oct 29, 2026
37 checks passed
@matt2e
matt2e deleted the onboarding/02-communities-join-owner-and-lookup branch October 29, 2026 01:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants