The chat screen stored the unsent message separately from the state cleared
by panic wipe, leaving old text visible after completion. Mention
suggestions could also put text back into the cleared input.
Cleared the input and its command and mention suggestions when the user
confirms the wipe. Rejected mention selections when suggestions are hidden
or the nickname is no longer in the current list, including older click
handlers and rows still visible during their closing animation.
Relays return stored events newest first, and the globe backfills from
many relays at once, so the first message inserted into a channel is
often its newest. Dropping by arrival order at the 500-message cap kept
older messages instead, which could expire while discarded newer ones
were still inside the hour, undercounting or zeroing a channel's chat
activity. Drop the message with the oldest timestamp.
Chatters were derived from the retained messages, which are capped at
500 per channel, so in a busy channel someone who wrote ten minutes ago
dropped out of the headcount and roster once 500 newer messages arrived,
and prune then forgot their last-seen time. Track each writer's newest
chat time separately; the cap now bounds only the message count.
The opening rotation lands at about 4x zoom while keeping level 1, and
minus was enabled only above level 1, so it was dead with most of the
globe off screen. Minus now also zooms out to the whole globe from a
zoomed-in level 1, and disabled rail icons are dimmed; their fixed tint
had hidden the disabled state.
Zooming out in place exposed a crash in the flight path: arc() returns
rounding noise rather than zero for an unmoved center, and for such a
tiny arc ln(sqrt(b^2 + 1) - b) cancels to ln(0), making the zoom NaN.
The whole-globe button made the same flight. Use the equivalent
-asinh(b) and d3's zoom-only threshold.
The flight curve's position already divides by the arc length, so
dividing again was only right for one-radian flights: longer arcs
stopped short and snapped onto the target on the last frame, shorter
ones overshot. Cover non-unit arcs in the tests.
The opening rotation now zooms from 72% of the whole globe to 80% of
the zoom at which autoPrecision switches to two-letter cells (~4.1x),
so it lands on a regional view while the selection stays at level 1.
The end zoom is derived from the same cell-size rule autoPrecision
uses (thresholdZoomForPrecision), so it cannot drift past the threshold.
The intro stays on the glide path so rotation and zoom move together,
and a fly-to that starts below the minimum zoom (an interrupted intro)
no longer snaps up on its first frame.
The opening rotation had lost the zoom-in it used to have. It now
starts at 72% of the whole-globe size and grows into it while turning
to the location, as one 1.4 s eased move, so the selection stays at
level 1 and the entire globe stays in view.
Pinching during an interrupted intro continues from the current size
instead of snapping up to the minimum, and never pinches further out.
Flying into a fine geohash was fast, rough and jittery:
- animateTo panned linearly while zooming exponentially over a fixed
duration, so near the end the remaining fraction of a degree became
a huge on-screen jump (the target was still ~130 screens away at 99%
progress). Large zoom changes now follow GlobeFlight, the van Wijk
and Nuij smooth pan-and-zoom path used by d3-zoom and Mapbox flyTo,
along a great circle, with duration scaled to the distance.
- The target precision was forced for the whole flight, so a fine cell
was re-selected nearly every frame, recomposing the card each time.
Precision now follows the zoom and lands on the target at the end.
- Every selection change fired a haptic tick, buzzing through any
programmatic flight. Ticks now fire only for user-driven changes.
- The view center was a Float; at deep zoom one Float step of latitude
spans several pixels. It is now a Double.
Opening the picker on a selected geohash no longer dives into it: it
plays the same whole-globe rotation as the no-selection case, turning
to face that location at level 1 so the world's activity stays in view.
Addresses review feedback: connectivity was checked once, after the
initial grace period, and the publish loop could only ever set LIVE. A
connection lost while the picker was open stayed LIVE forever, and a
connection made after the grace period left a quiet globe OFFLINE until
traffic arrived.
The monitor now re-derives its status on every 500 ms tick from its own
relays' connection state, via activityStatus(): LIVE when connected and
either traffic has arrived or the grace period passed, OFFLINE when no
relay is connected after it, CONNECTING otherwise. Relay state is
polled because the relay manager mutates its relay objects in place, so
its relay list flow can drop connection changes as equal values.
The pop-in overshoot, staggered cascade and markers flying into and out
of clusters were too much motion. Markers now stay exactly where the
globe puts them: a new one fades in while growing from 60% to full size
over 200 ms with an ease-out and no overshoot, and a vanished one fades
out while shrinking slightly over 150 ms. Cluster size easing, the limb
fade and the system animator scale are unchanged.
Addresses review feedback: the monitor republished its snapshot only
when an event arrived or prune removed something. A chatter stays
listed for the hour, so prune keeps them after their five-minute online
window ends and returns false; the published online count then stayed
inflated for up to an hour.
The 15-second prune tick now always recomputes the snapshot (the
StateFlow drops unchanged ones). Only real traffic marks the sweep
LIVE, so a timed refresh cannot flip an offline globe to live.
Land and sea swapped colors, and flickered, whenever a large landmass
crossed the globe's limb. drawLandFill decides whether to invert a
ring's fill by testing sample points with ringContainsLocation, which
cast its ray along a parallel using longitudes relative to the probe.
Edges near the probe's antimeridian wrapped from +180 to -180 and
landed near zero, so a Pacific probe saw Europe's coast as crossings:
Eurasia "contained" the Pacific and the middle of North America, its
fill inverted, and the result flipped as the view rotated. The test now
counts edges crossing the probe's meridian north of it, following each
edge the short way around, as the iOS globe does. GlobeLandFillTest
checks oceans and continents against the bundled Natural Earth land.
Moving frames also strobed between detail levels. Detail was re-chosen
every 8 frames and could step back up mid-gesture, but shedding detail
is what makes frames fast again, so slow devices oscillated and
neighbor cells, labels and borders flashed. GlobeMotionGovernor now only
steps detail down within a gesture, restores full detail at rest, and
starts the next gesture at the level the device sustains.
The geohash grid, neighbor cells and city dots now draw at every detail
level instead of vanishing on touch and popping back on release; only
borders, city labels, graticule density and coastline resolution vary.
Markers used to pop on and off as data arrived, the globe turned, or
zoom merged and split clusters. GlobeMarkerAnimator tracks markers by
their busiest channel between frames:
- New markers pop in with a slight overshoot (~280 ms); a new batch is
staggered over ~110 ms so it cascades rather than flashing at once.
- Vanished markers shrink and fade out quickly (~160 ms).
- On zoom, a marker merging into a cluster flies into it and markers
splitting from a cluster fly out of it; cluster size changes ease.
- Markers fade and shrink toward the limb instead of being culled at
a depth threshold.
Durations follow the system animator scale, so Remove animations makes
every change instant. The layer reads the frame clock only while a
marker is moving or pulsing, so a settled globe still stops redrawing.
Addresses review feedback on the observe-only sweep, which bypasses the
relay manager's checks:
- Signature verification was skipped for events that only refreshed
known activity, so a relay could replay a known chat id with new
pubkeys or keep people online with unsigned heartbeats. A new
VerifiedEventFilter verifies every distinct event once and drops
repeats of an admitted id without reading their fields. Failed ids
are not remembered, so a forged copy cannot block the genuine event.
- Proof-of-work ran before verification and throws on a non-hex id,
which stopped the inbox consumer. Verification now runs first, and
each event is ingested inside its own error boundary.
GeohashActivityAggregator.wouldGrow is removed with its only caller.
The channel sheet and the globe used different code to count people in
a geohash, so the numbers disagreed. GeohashActivityAggregator is now
the single rule for both: anyone with a heartbeat or message in the
last five minutes, plus anyone who wrote in the last hour.
- GeohashParticipantStore keeps its API but derives its participant
set from the aggregator. Chat events pass kind, id and content
through the new GeohashRepository.recordActivity so authors are kept
for the hour; geohash DMs still count as presence.
- The aggregator works in milliseconds and clamps future timestamps to
now, keeping the store's guard against skewed clocks.
- The globe adopts the sheet's filters: blocked users are excluded from
every count, and the proof-of-work gate applies to chat events.
- The 5-minute cutoffs scattered through GeohashRepository are gone;
counts and nickname disambiguation read the already-filtered set.
The globe picker now shows where people are chatting, in the spirit of
bitmap.lat. While it is open, a monitor subscribes to kind 20000 (last
hour) and kind 20001 (last five minutes) with no geohash filter, on a
worldwide spread of relays plus the few nearest the resting view. Relays
it opened are released when the picker stops.
- Dots are sized by people and colored by chat volume; heartbeat-only
channels draw as quiet rings, recent chat pulses, and overlapping dots
merge into clusters that split as you zoom.
- Tapping a dot flies to that geohash and selects it; tapping a cluster
zooms in and lists its busiest channels. The card shows people and
messages per hour, a Join button, and the busiest chat inside the cell.
- Clients heartbeat into region, province and city channels under
separate identities, so people are estimated from the busiest
precision level instead of summed.
- Signatures are verified for any event that would add a person or a
message, so forged events cannot inflate counts.
- Observe-only subscriptions bypass the shared deduplicator so the
sweep never consumes events the open chat still needs.
The picker chrome is redesigned: top bar with a live status chip, a
zoom rail with a whole-globe button, gesture hints that fade after
first touch, and opaque surfaces that no longer show their own shadow.
With no starting geohash it opens on the whole globe.
ui_demo_seed copied the installation's nickname into the demo
conversation, so a debug build on a real watch could put a real name
in screenshots and mix demo rows into real history. Seeding now runs
only on an auto-generated nickname (or the demo name it set itself),
and switches the install to a fixed demo name.
- Apply the bitchat mono font to every Wear Material type role; the
tokens name the device font, so defaultFontFamily missed titles,
buttons, dialogs and the clock.
- Swipe-to-dismiss navigation that reveals the previous screen, with
push/back entrance motion instead of a double-exposed crossfade.
Revealed screens stay passive so a cancelled swipe changes nothing.
- App-wide TimeText on list screens, hidden in chat, faded during swipes.
- Edge buttons for setup and verification actions; the Wear confirmation
layout for the alerts prompt. No auto keyboard on first-run nickname.
- People: uniform rows with unread pills. Live push-to-talk moves to the
menu as a SwitchButton; Quit confirm uses the error color.
- Push-to-talk overlay: edge budget ring, cancel hint, cleaner cancel
morph. Text input names its destination and matches the action bar.
- Fix history rows replaying their entrance on scroll, edge-fade dimming
text inside the display, a lit waveform bar at rest, and plural and
capitalization slips in content descriptions.
- Aspect-aware full-screen image fit for round displays.
- Condense copy throughout for 192 dp screens.
Debug-only test hook commands that fill the watch UI with synthetic
peers, messages, an image and a voice note for screenshot reviews,
without sending packets: ui_demo_seed, ui_demo_peers and
ui_demo_clear_peers.
Swiping the public mesh chat to the right reveals an app menu page
(Wear HorizontalPagerScaffold). The menu shows mesh status and a
"Quit bitchat" button that, after confirmation, stops the mesh
foreground service and closes the task.
Swiping from the menu's left edge still falls through to system
swipe-to-dismiss. Page swipes are disabled while push-to-talk or the
full-screen image viewer owns the gesture.
A copy with a zero-width space between letters, an accent on every
letter, or math-bold or fullwidth letters looks the same to a reader
but defeated both the per-sender and cross-user repeat checks.
Normalize text for comparison by applying NFKD (which folds fullwidth
and styled letters to plain ones and splits off accents), dropping
combining marks and invisible characters, collapsing whitespace and
lowercasing. Displayed and stored text is unchanged. Input is capped
before normalizing to bound the work per message.
Tapping the QR action closed the network sheet, briefly showed the chat
screen, then opened the verify sheet; closing verify reopened the network
sheet through a reopenSidebarAfterVerification flag. Open the verify
sheet on top of the network sheet instead, the way the private chat sheet
already hosts security verification, so dismissing it (close button or
back) simply reveals the network sheet underneath. The reopen flag and the
fromSidebar parameter are no longer needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>