Skip to content

feat(mobile): browse and join open channels - #6243

Open
brow wants to merge 9 commits into
mainfrom
tomb/buzz-13-browse-and-join-channels-in-mobile
Open

feat(mobile): browse and join open channels#6243
brow wants to merge 9 commits into
mainfrom
tomb/buzz-13-browse-and-join-channels-in-mobile

Conversation

@brow

@brow brow commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Mobile previously exposed no way to browse or join channels.

Users can now browse and join eligible open channels from the Home quick-actions menu. The public directory loads on demand when Browse channels opens, while the existing kind 9021 join path refreshes membership after success.

Browse channels Join channel
Browse channels Join channel

How is it tested?

Manually tested (see screenshots) and added tests:

  • channels_provider_test.dart covers access filtering, independently paginated membership and directory queries, relay-capped pages, repeated-page termination, hard page caps, on-demand directory loading, load failures, retry, and cached-channel retention.
  • channels_page_test.dart covers browse eligibility, loading and retry states, quick-action layout, and scrolling and joining from a 500-channel directory.
  • search_page_test.dart covers discoverable open-channel results without presenting unknown membership counts as zero.

Local validation:

  • just mobile-check
  • just mobile-test (1,560 tests)
  • full pre-push gate

brow and others added 4 commits August 17, 2026 10:59
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
brow added 3 commits August 18, 2026 16:25
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
@brow
brow marked this pull request as ready for review August 19, 2026 19:11
@brow
brow requested a review from a team as a code owner August 19, 2026 19:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14b8a9e7c5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/channels_provider.dart Outdated
Comment thread mobile/lib/features/channels/channels_provider.dart
Signed-off-by: Tom Brow <tomb@block.xyz>
@brow
brow enabled auto-merge (squash) August 19, 2026 19:53

@jedwards27 jedwards27 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.

:bot: Jude’s code review agent

Requesting changes at exact head fafe4cb74452dd4ad1553c399426b9b6741a5cca.

Blocker: discard directory work that settles after the relay or identity changes

mobile/lib/features/channels/channels_provider.dart:173-205 captures the directory scope for the load-status label, but after awaiting _fetchChannelDirectoryMetas it unconditionally assigns _directoryMetas and merges those events into the channel list. retryDirectory() then unconditionally installs that completed fetch into current provider state at mobile/lib/features/channels/channels_provider.dart:930-957. The HTTP request snapshots its relay URL and signing key before its await (mobile/lib/shared/relay/relay_session.dart:171-190), so switching communities while Browse is loading lets community A's delayed response populate community B's current state and browse sheet. That crosses the tenant boundary in VISION.md:52-56.

A deterministic paused-response probe reproduced this twice independently: start A's directory query, switch relayConfigProvider to B and rebuild, then release A. Expected B's list to remain empty; actual current state contained A's channel. The shipped tests do not exercise this ordering.

Fence completion with the captured relay + identity scope and a request generation. Re-check after every await and before writing directory metadata, load status, subscriptions, or provider state. Please add deterministic stale-success and stale-failure regressions for both community and identity switches.

Validation

  • just mobile-check: pass at exact clean head.
  • Full just mobile-test: pass, 1,560 tests, at exact clean head.
  • GitHub Mobile and all completed required checks are green at this exact head.
  • Pagination's composite until + before_id cursor, repeated-page termination, access filtering, truthful unknown counts, and kind 9021 join followed by membership refresh were traced; no second material defect found.

The PR screenshots show the intended UI but are not a SHA-bearing exact-head native receipt. After the fix, please attach fresh exact-head simulator/native workflow evidence for browse, join, and membership refresh.

A community or identity switch changes which tenant the channel directory
belongs to, but an in-flight directory query kept writing its response into
whatever scope was active when it landed. The user switched community, and
the previous community's open channels appeared in the new community's list.
The load status had the same hole on the failure path: a request that failed
after the switch marked the new scope as errored.

VISION.md describes tenant isolation as a boundary, not a filter, so a
retired response is discarded rather than merged.

The fix captures the relay-and-identity scope plus a monotonic generation at
request time, then re-checks both after the await and before every write, on
the success path and the failure path alike. A retired request throws
_StaleDirectoryRequest, which retryDirectory swallows so it writes neither
the channel list nor the load status. An in-scope failure still returns null,
which keeps the existing "retain the cached discovery" behavior.

The loader lives in the sibling part file channel_directory.dart because
channels_provider.dart already sits at the 1000-line ceiling that
`just file-size-check` enforces, so it may not grow at all. Its inline fetch
block moved into the loader and a `forRef` factory keeps the construction to one
line, which leaves the provider slightly smaller than before. The split is
mechanical: the fence moved out, nothing else changed shape.

Tests: four new deterministic arms in channels_provider_test.dart, covering
stale success and stale failure for both a community switch and an identity
switch. The fake relay session gained a pausable directory query so the switch
can be interleaved between request and response, and a request-time snapshot so
a paused response reflects the community that issued it. All four failed
against the unfixed provider, and the first reproduces the reported symptom
exactly. Positive control: mutating the fence's isCurrent() to `=> true` turns
all four red again, so the fence and not the harness is what makes them pass.

Suite moved from 1560 to 1564 tests, all passing. `just mobile-check` and
`just file-size-check` are clean.

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
@brow
brow requested a review from jedwards27 August 21, 2026 18:59
@brow

brow commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

🤖 @jedwards27 Blocker addressed in a5fc7c4.

The defect, confirmed

Reproduced before fixing. A directory request captured its relay and identity only for the load-status label. After awaiting _fetchChannelDirectoryMetas, it assigned _directoryMetas and merged those events into the channel list without re-checking that the scope was still current, and retryDirectory() then installed that result into provider state. Switching communities while Browse was loading let community A's delayed response populate community B's state, exactly as you described. The failure path had the same hole in the other direction: a request that failed after the switch marked the new scope as errored.

The fence

_ChannelDirectoryLoader captures the relay-and-identity scope plus a monotonic generation at request time. It re-checks both after the await and before every write, on the success path and the failure path. A retired request throws _StaleDirectoryRequest, which retryDirectory() catches and returns from, so it writes neither the channel list nor the load status. ChannelsNotifier.build() retires any in-flight request when the scope changes. An in-scope failure still returns null, preserving the existing "retain the cached discovery" behavior.

The fence lives in the channel_directory.dart part file rather than beside the call site. channels_provider.dart was already at the 1000-line ceiling that just file-size-check enforces, so it could not grow at all. Moving the inline fetch block into the loader and adding a forRef factory left the provider at 995 lines, slightly smaller than before.

Regressions, four arms as requested

  • community switch discards a stale directory success from the old relay
  • community switch discards a stale directory failure from the old relay
  • identity switch discards a stale directory success from the old identity
  • identity switch discards a stale directory failure from the old identity

The fake relay session gained a pausable directory query, so the community or identity switch is interleaved between the request and its response, and a request-time snapshot so a paused response reflects the community that issued it. All four fail against the unfixed provider. The first reproduces your symptom exactly: community A's channel present in community B's list.

Positive control: mutating the fence's isCurrent() to => true turns all four red. The pristine fence passes all four. So the fence, and not the test harness, is what makes them pass.

Validation

At a5fc7c4d8 on a clean tree: just mobile-check passes, just file-size-check passes, and the full just mobile-test passes with 1564 tests. Your control at fafe4cb74 was 1560, and the difference is these four arms.

This branch is behind main, which changed channels_page.dart outside this commit. main changed none of the three files this commit touches. Rather than merge main into the branch, I validated the merge separately: the 3-way merge is clean, and the merged tree passes mobile-check, file-size-check and the full suite at 1595 tests. So the fence holds against current main.

Still outstanding from your review

The exact-head native receipt for browse, join and membership refresh is not attached yet. I am attempting it now and will post it as a follow-up comment. I am not going to claim evidence I have not produced, so if the rig does not work at this head I will say so here plainly rather than leave the ask looking satisfied.

@brow

brow commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Review comments addressed at a5fc7c4. Three items, one code change.

1. Stale directory across a relay or identity switch (blocker, fixed)

A directory request captured its scope only for the load-status label, then wrote directory metadata and the merged channel list after the await without re-checking it. Switching communities while Browse was loading let the previous community's delayed response populate the new one. The failure path had the same hole: a request failing after the switch marked the new scope as errored.

_ChannelDirectoryLoader now captures the relay-and-identity scope plus a monotonic generation at request time and re-checks both after the await and before every write, on success and on failure. A retired request throws, and the caller writes neither the channel list nor the load status. The loader sits in the channel_directory.dart part file because channels_provider.dart was already at the 1000-line ceiling that just file-size-check enforces; the provider ended at 995 lines, slightly smaller than before.

Four new deterministic regressions cover stale success and stale failure for both a community switch and an identity switch. All four fail against the unfixed provider. Forcing the fence's isCurrent() to => true turns all four red again, which shows the fence rather than the harness is what passes them.

2. Load the directory independently of the channel list (P2, not reproduced)

Already fixed in fafe4cb74, the commit after the 14b8a9e7c5 this was filed against. Both _fetch and _fetchChannels now default fetchDirectory to false, so build() publishes the membership-backed list without downloading the directory. Only retryDirectory() and ensureDirectoryLoaded() pass true, when the Browse surface asks for discovery. The join path is clear for the same reason: joinChannel refreshes through ChannelsNotifier.refresh(), which takes the false default, and the backstop refresh passes false explicitly. Covered by membership refresh does not refetch a loaded directory.

3. False "0 members" on discovered channels (P2, not reproduced)

Also already fixed in fafe4cb74, at the render site rather than the data site. search_page.dart now renders the member count only when channel.isMember, so an unjoined directory entry shows the Open badge and no count instead of the memberCount: 0 initializer. Covered by a case in search_page_test.dart.

Validation

At a5fc7c4d8 on a clean tree: just mobile-check passes, just file-size-check passes, full just mobile-test passes at 1564 tests. The control at fafe4cb74 was 1560, and the difference is the four new arms.

This branch is behind main, which changed channels_page.dart outside this commit and none of the three files this commit touches. Rather than merge main in, I validated the merge separately: the 3-way merge is clean and the merged tree passes the full suite at 1595 tests, so the fix holds against current main.

Re-review requested from jedwards27 at this head. The exact-head native receipt for browse, join and membership refresh is still pending, and I will post it as a follow-up comment or state the gap plainly if the rig does not work at this head.

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