What
The globe geohash picker now shows where people are chatting, as on Android (permissionlesstech/bitchat-android#1017). Tap a dot to fly to that geohash, then join it in one tap. The picker chrome is redesigned with native iOS patterns, and the location channels sheet now counts people with the same rule as the globe.
How it works
- Data. While the picker is open,
GeohashActivityMonitorsubscribes to kind 20000 (last hour) and kind 20001 (last five minutes) with nogfilter, so the request reveals nothing about the user's location. It uses 24 relays spread around the world, plus the 3 nearest wherever the user rests the globe. Subscriptions close when the picker goes away or the app backgrounds, andNostrRelayManager.releaseRelayscloses the relays the monitor opened once nothing else targets them. - Counting.
GeohashActivityAggregatorkeeps distinct people, chatters and messages per exact channel. Clients heartbeat into region, province and city channels under separate identities, so headcounts come from the busiest precision level rather than a sum (estimateDistinctPeople). - Integrity. No extra filter is needed on iOS.
NostrRelayManageralready verifies every event's signature and id off the main actor before delivery, and deduplicates per subscription. So the sweep never consumes events the open chat still needs, and a replayed id with a forged pubkey is dropped before it is read. - Timestamps. Events count at their own (clamped) timestamp. Behind a slow Tor bootstrap the relay manager releases EOSE callbacks on a timeout before any history arrives, so EOSE can't tell history from live traffic for the sweep.
- Markers. Chat dots are solid and sized by people, shading from green through yellow to orange as messages per hour rise. Recent chat pulses. Heartbeat-only channels are quiet rings. Overlapping dots merge into clusters and split as you zoom in. Markers draw on their own canvas, so the pulse never redraws globe geometry.
- Animation. Same calm in-place fade as Android's latest commit: a new marker fades in while growing from 60% to full size over 0.2 s, a vanished one fades out while shrinking to 70% over 0.15 s. Markers never move across the surface, cluster size changes ease, markers fade toward the limb, and Reduce Motion makes every change instant.
- Opening. As on Android, the picker opens with a rotation that turns to the selected geohash (or the coarsest nearby channel) while zooming into a regional view. It starts at 72% of the whole-globe size and ends at 80% of the zoom where two-letter cells take over (~4.1×), rotation and zoom together as one 1.4 s glide, so the selection stays at level 1. The end zoom comes from the same rule that picks precision (
GlobeMath.thresholdZoom). Before, a selected geohash made it dive into that cell, which for a fine geohash was fast and jittery. A pinch or fly-to started during the opening continues from where it is instead of snapping. - Flights. Every animated move follows a smooth zoom-and-pan path (
GlobeFlight, van Wijk & Nuij, the model behindd3.interpolateZoom). The view turns along the great circle while zoom follows the curve that keeps perceived speed constant. Before, zoom interpolated exponentially and the center linearly, so a long flight zoomed in first and then streamed hundreds of cells past the screen per frame. - Interaction.
- Tapping a dot flies to its geohash at its own precision.
- Tapping a cluster zooms in and lists its busiest channels.
- The card shows people and messages per hour, a Join button, and a shortcut to the busiest chat inside the selected cell.
- Chrome (iOS-native).
- Round close button and a live status chip (chats and people online). It follows the picker's own relays on every tick: "live" while one is connected, "connecting" while the network is available (including while Tor bootstraps or relays reconnect), "offline" otherwise.
- A Maps-style control stack: whole globe,
+, current geohash length,−. - Gesture hints that fade after the first touch or control use.
- Liquid Glass surfaces under the glass theme (material below iOS 26), an opaque panel with a hairline under matrix.
- Press feedback, a light haptic on marker taps and a success haptic on Join.
- VoiceOver can't aim at markers, so the five busiest chats are offered as actions on the globe.
- 13 new strings in all 30 locales; 11 reuse Android's translations. The unused
geohash_picker.instruction,geohash_picker.selectandgeohash_picker.accessibility.selectare removed.
One counting rule for the sheet and the globe
The channel sheet's "N people" and the globe share GeohashActivityAggregator. Someone counts if they sent a heartbeat or message in the last 5 minutes, or wrote in the last hour.
- Behaviour changes in the sheet.
- People who chatted in the last hour still count after their heartbeats stop. Previously everyone dropped out after 5 minutes.
- Blocked people are left out of the count, as they already were from the roster.
- Authors are tracked apart from message history. History is capped at 500 messages per channel, but each author's newest message time is kept separately, so a very busy channel never drops someone who wrote inside the hour.
- Teleport badges keep the 5-minute window. Chat authors stay in the roster for the hour, but teleport badges follow who was active in the last 5 minutes:
- a teleported message replayed from ten minutes ago doesn't badge its author (
expiredReplayedTeleportTagDoesNotFollowItsAuthorBackIn); - a teleported writer who goes quiet loses the badge after 5 minutes. The tracker publishes
recentlyPresentIDsfor this, refreshed on its 30 s timer.
- a teleported message replayed from ten minutes ago doesn't badge its author (
- Coverage still differs. The globe reads more relays than a joined channel, so counts can differ by coverage, but not by rule.
Differences from the Android PR
- No observe-only subscriptions or
VerifiedEventFilter. iOS already verifies and deduplicates per subscription (see Integrity). - Online counts refresh as the presence window lapses. The snapshot republishes at least every 15 s (Android fixed the same issue in
1494ba96). - Stable ordering. Channels with equal counts sort by geohash, so a cluster's busiest channel (the marker's identity) doesn't flip between snapshots.
- Status chip. Same rule as Android's latest (re-derived each tick from the picker's relays), except "offline" means the network is disallowed or unreachable rather than a fixed grace period having passed.
Screenshots
All activity in these screenshots is synthetic demo data around San Francisco. A local seeder (not part of this PR) fed made-up events through the real ingest path, with the app offline so no relay data could appear.
| Before | After: world view | Cluster tapped |
|---|---|---|
| Busiest chat inside | Channel selected | Dark mode |
|---|---|---|
| Liquid Glass, dark | Liquid Glass, light |
|---|---|
Zooming out to the whole globe, frames 33 ms apart: dots fade and merge where they sit.
Testing
swift test: 2,237 Swift Testing tests and 214 XCTest tests pass. The iOS simulator build has no warnings in changed files.scripts/check-perf-floors.sh: all 16 benchmarks clear their floors;geochat.participantHeartbeatruns at 1.42M events/s locally (floor 150k). The aggregator mutates channels in place, since copying them per heartbeat had cost O(participants).periphery scan --strictreports nothing in the files this PR changes.- New tests in
GeohashActivityTests:- aggregator: windows, the hour rule for chatters, authors outliving the message cap, one count per person, future-timestamp clamping, exclusion, pruning, per-level headcount, busiest-inside
- monitor: event filtering, online counts expiring without new events, blocked people hidden, start/stop without relays, subscriptions retried when network access returns (against a relay manager with idle sockets)
- chat event ids: only chat text counts for the hour; our own sent messages now pass theirs too
- tracker: chat authors stay for the hour, quiet authors left out of
recentlyPresentIDs, blocked people left out of the count
ChatViewModelBootstrapperTests: a teleported writer's badge lapses with presence while they stay in the roster- layout: clustering and splitting on zoom, far-side culling, hit testing, sizing, heat colors, compact counts
- animator: in-place fade in and out, no overshoot, merging without travelling, eased cluster growth, Reduce Motion
- New
GlobeFlightTests: exact endpoints, a deep flight's per-frame screen motion stays under a quarter screen (over 20× less than the old interpolation), zooming in place keeps the center, same-zoom spins never zoom out.GlobeStateTestschecks that the opening to a block-level geohash rotates while zooming into a regional view below the two-letter threshold and stays at precision 1, that the threshold zoom agrees withautoPrecisionfor precisions 2–6, and that an interrupted opening continues from below the minimum zoom.GeohashActivityMonitorTestschecks the status chip's rule. - Checked in the iPhone 17 Pro simulator (iOS 27), matrix and Liquid Glass, light and dark:
- markers appear, cluster and split on zoom
- cluster tap lists channels; picking a row flies to it
- single-dot tap and the busiest-inside row fly to and select the geohash
- Join teleports into the channel; the whole-globe and zoom buttons work
- a screen recording of zooming out and back shows markers pinned to the surface
- recordings of opening on a block-level geohash (rotating and zooming together into a regional view at level 1) and of a dot tap flying from the whole globe to a far province cell (turns, then zooms in with the target centered)
- Not checked: a physical device, VoiceOver, the macOS picker UI (macOS compiles as part of
swift test).
Activity
24💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c2838a5d2
ℹ️ 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".
@codex review
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47e29b1592
ℹ️ 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".
@codex review
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 135d051cd4
ℹ️ 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".
@codex review
Codex Review: Didn't find any major issues. Keep it up!
Reviewed commit: 0cd48e7d34
ℹ️ 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".
Codex Review Summary
This comment shows the latest Codex review activity on this pull request.
0cd48e7ℹ️ 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.