Summary
Stacked on #1792; the base branch is feat/iphone-duo. On the iPhone Duo's inner display, #mesh and a geohash channel now sit side by side, one on each side of the fold. Each has its own header, timeline and composer.
Before (#1792): the inner display split into the selected channel's timeline beside the people list or the open DM.
After:
| Pane | Shows |
|---|---|
| Leading (#mesh, always) | Logo, nickname, notices and banners; the mesh people button; the mesh timeline; a composer that only ever sends to #mesh. |
| Trailing (one of) | The selected geohash channel: its badge switches channels, plus bookmark, share and its people. Or the location-channel picker, inline, when no geohash is selected. Or an open DM, covering the channel until you go back. |
- People lists are popovers from each pane's people button: mesh peers on the left, the geohash's participants on the right. Picking someone opens the DM in the trailing pane.
- Folding shows the pane you last touched. Unfolding restores the other one.
- The inline picker never asks for location by itself. The existing tap-to-enable button is the only path. Once location is allowed, it takes one fix when it appears, not continuous updates.
- Presence follows the screen.
- The trailing geohash is live while it is selected, including while a DM covers it.
- The idle picker means no geohash is live.
- Unchanged: every other iPhone, the Duo's outer display and macOS. A few intended exceptions on every iPhone are listed below.
How it works
One selection, two timelines
- The channel selection keeps its one meaning. In the two-pane layout it is the trailing pane's channel, and
.meshthere means the idle picker. - #mesh is pinned to the leading pane by a second
PublicChatModel(pinnedTo: .mesh). - Subscriptions, presence, the geohash identity, read receipts and notification suppression are unchanged. Whichever geohash is on screen is the live one.
- Arrivals for both on-screen timelines are batched and de-duplicated per conversation (
mesh|…,geo:<gh>|…), so the same text on both is no longer dropped.
Explicit destinations (ConversationTarget)
- Every send, command, photo, voice note and list action carries the conversation it came from. Nothing on the send path reads the global selection. The one exception re-resolves the routing ID of the DM that is already open.
- Commands run in their composer's scope.
/who,/clearand/hugtyped in the #mesh pane act on #mesh while a geohash is selected, and output lands where the command was typed. - A geohash DM opened in the trailing pane is tied to the geohash it opened over. If the trailing pane moves to another geohash, the DM ends rather than signing your next reply with a different geohash identity.
Composer sessions
- Each composer has its own text, suggestions and focus, and owns its mic hold.
- Drafts follow their conversation across folds and channel switches.
Pane state
SplitPaneState, built on the purePaneFoldPolicy, decides:- what the trailing pane shows;
- fold and unfold, including parking the geohash and a mesh DM;
- the geohash-DM exception;
- when popovers close;
- what panic resets.
- ContentView observes no per-message model, and each pane's list observes only its own timeline.
- "Last touched" comes from a passive window-level touch observer, plus typing and composer focus. The observer never delays or cancels another gesture.
Fixes found along the way
These also apply outside the two-pane layout.
- A geohash DM signs with the geohash its composer was scoped to when you pressed send. It used to read the selection a few hops later, at signing time.
- Clearing a geohash timeline no longer deletes the photos and voice notes you sent on #mesh or in DMs. Our outgoing media only ever lives there.
/block <name>resolves the name only in the channel it was typed in.- Panic drops public messages waiting for the next batched flush. They used to land in the freshly wiped timeline.
- External
bitchat://geohashlinks are opened once, not once per mounted message list. - Command output no longer recreates a group chat that was just left or removed.
- The legacy-media consent dialog no longer declines a send unseen. That used to happen when SwiftUI took it down unanswered, for example when another presentation was refused. Only its buttons answer it now.
Behaviour changes on every iPhone (intended)
- A DM's photo or voice-note failure line shows in that DM, not in the public timeline behind the DM sheet.
/msgto a blocked person typed in a DM, and "direct message" on a blocked member of a group, post the refusal in that conversation.- While the DM sheet is open, the public composer keeps its draft live instead of saving and reloading it (which also capped it at 8,000 characters).
- Panic closes an open photo picker.
- An open notices sheet keeps the place it opened with when a link switches the channel under it.
@suggestions in a mesh DM opened while a geohash is selected offer mesh peers, not that geohash's names.
Verification
| Check | Head | Result |
|---|---|---|
| iOS simulator suite (iPhone 17 Pro, iOS 27.0, CI flags) | d8cad164 | 2700/2700 passed |
SwiftPM (swift test, macOS, as CI runs it) | d8cad164 | 2348/2348 passed |
| macOS build and build-for-testing | d8cad164 | Green |
Periphery --strict (macOS scheme) | d8cad164 | No new findings |
SwiftLint --strict (whole repo) | b56a8edb | Nothing new. Locally, SwiftLint 0.65.1 flags the 2 known errors in untouched ImagePreviewView.swift; CI pins 0.65.0. |
The final head b56a8edb only drops two trailing commas from test literals.
What the tests cover
- Every pane, fold, unfold, popover, panic and screenshot path, driven through
ContentViewitself in a wide test window. - Pure tests for the pane state and fold rules.
- Target-level tests showing each composer, command, photo and voice note goes to its own conversation.
- Mutation checks confirm that the key tests fail with their fix reverted.
Rendered from the test host (1000×800, regular width, matrix and Liquid Glass):
- idle picker beside #mesh;
- a geohash beside #mesh;
- both people popovers;
- a DM over the geohash;
- a fold with #mesh last touched, which shows #mesh alone;
- the unfold that follows, which restores the geohash.
This turned up one bug, now fixed: the trailing people popover could open sideways across the hinge. The popovers now only open under their own bar.
Adversarial review
- Three build waves, each reviewed independently.
- A final five-lens review (privacy, state machine, presentation, runtime and tests, simplification), with every finding re-checked by a skeptic: 24 confirmed, 1 refuted.
- All 24 are fixed, and a verifier re-checked each fix.
Still to do before this leaves draft
- An iPhone Duo simulator pass on the inner display:
- the fold region and corner clearance;
- real fold and unfold;
- the keyboard with two composers;
- touch focus picking the pane a fold shows.
- VoiceOver reading order with the grouped panes, on a device.
Notes for reviewers
- VoiceOver: navigating with VoiceOver doesn't count as "last touched". Its own cursor moves can't be told apart from a person's swipe. Activations still count (opening a DM, composer focus, choosing a channel). The panes are grouped for VoiceOver.
- Test-only hook:
splitLayoutTestTaps, an iOS-only environment value that only the smoke tests set, lets windowed tests press the people buttons. A test host can't press SwiftUI buttons. It runs exactly the closures the buttons get. - CI timing: the iOS job's first attempt hit its 20-minute limit while the runner spent 12 minutes booting the simulator. The re-run passed in 16.3 minutes: build 4.0, simulator start 5.2, tests 4.1. The slowest suite is View Smoke Tests at 70 s, which holds the windowed split tests.
- Not in this PR: per-pane notices (the leading bar keeps today's single notices pin); several live geohashes at once.
🤖 Generated with Claude Code
Activity
100This pull request is a draft
Merging is unavailable until it is marked ready for review.
Codex Review Summary
This comment shows the latest Codex review activity on this pull request.
8b5594bℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.