* Add Persian (fa) localization and an in-app language picker
Persian was the one notable gap in the 29-language catalog. Translate all
381 strings (plus the share extension) with proper plural substitutions,
and register fa in knownRegions.
Settings gains a LANGUAGE section: a picker over every language the bundle
ships (native names via Locale), backed by an AppleLanguages override so
users can run bitchat in a language different from the device's. Localization
resolves at process start, so the picker surfaces a restart note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove unused AppLanguageSettings.currentOverride (Periphery)
AppInfoView reads the override via @AppStorage, so the accessor was dead
code and failed the Periphery CI scan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* Bound public rate-limit buckets against attacker-keyed growth.
Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stop Cashu-looking text from skipping long-message guards.
Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cap teleported geohash participant markers.
Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bound untrusted Nostr relay frames and event tags.
Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fail soft when Noise handshake state is unexpectedly missing.
Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cap geohash nickname cache from remote Nostr events.
FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Avoid overlapping exclusive access in the rate limiter.
Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix rate-limiter tests for mutating allow under #expect.
Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop unused WebSocket data helper; reset rate limiter on panic wipe.
dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: use country-level resolution for low-precision geohashes (#887)
* fix: skip admin fallback when country exists but is duplicate
Prevents mixed labels like "United Kingdom and Scotland" for
single-country geohashes. Admin fallback now only triggers when
no country is available from the placemark.
* fix: migrate stale low-precision bookmark names so country-first logic applies
Users who bookmarked a <=2-char geohash before the country-first resolver
kept the old administrativeArea cache (e.g. "England" for `gc`) because
resolveBookmarkNameIfNeeded bails when bookmarkNames[gh] is non-nil. Add a
one-shot, versioned migration that drops cached names for <=2-char geohashes
on load; the next LocationChannelsSheet .onAppear re-resolves them via the
fixed logic. Higher-precision entries are untouched.
* Fix low-precision geohash country names
---------
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.
Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
* Consolidate duplicate BLE links after restore; suppress duplicate fragment streams; onChange coalescing
Field evidence (two-phone test after BLE state-restoration relaunches):
both phones held 2-3 simultaneous same-role connections to each other —
one side received every packet from three distinct centrals all bound to
the same peer — so every PTT voice frame arrived 3x, and a 41KB voice
file went out as TWO complete independent 89-fragment streams (different
assembly ids), both fully reassembled and the duplicate only dropped at
the very end by messageID dedup. 2-3x airtime/battery on all traffic
plus doubled reassembly memory.
Root causes and fixes:
- Duplicate links stayed unbound forever: announces (the only packet
that binds a link to a peer) went through the per-peer duplicate-link
collapse, so a peer's second/third link never received the announce it
needed to become bound — and unbound links pass the collapse untouched,
so every broadcast sprayed down all of them. Direct announces now
bypass the collapse and reach every live link (relayed announces keep
it); once bound, the existing collapse dedups all traffic.
- Same-role link retirement: a verified direct announce now consolidates
our central-role connections to that peer — keep the link the announce
arrived on (or the most recently bound one), cancel other connected
links bound to the same peer. One connection per role per peer is the
normal dual-role topology; only same-role duplicates are touched, only
links already announce-bound are retired (never pre-announce links),
at most one retirement per peer per rebind-cooldown window, and the
peer keeps a live link either way. Directness stays forgeable (TTL is
unsigned), so a replay could nominate the survivor — bounded by the
cooldown and by DM routing's existing canDeliverSecurely gate. A
rotation rebind now also cancels other stale links still bound to the
rotated-away ID, so the ghost identity retires promptly.
- Deterministic preferred-link collapse: when several bound links to one
peer are candidates, collapse now keeps the peer's most recently bound
link (the reverse-mapped one) instead of dictionary order; links
without a discovered characteristic are excluded from fanout (they
cannot be written to, and could silently eat a peer's collapsed copy).
links(to:) now reports all bound peripheral links, and removing one
duplicate no longer clobbers the reverse map of the survivor.
- Duplicate fragment streams: a transferId-less resend (gossip-sync
replay, spool) of file content already being fragmented out to a
covering audience is dropped at the outbound scheduler (broadcast
covers everyone; directed covers its recipient). App-initiated sends
carry an explicit transferId the progress UI tracks and always run.
A peer that asks after the stream completes still gets a resend.
- didUnsubscribeFrom no longer flaps a peer that is still live on other
links (the far side retiring its duplicate arrives as an unsubscribe);
didDisconnectPeripheral bookkeeping is skipped for self-retired links
by removing the store entry before cancelling.
Also: guard the DeliveryStatus onChange state write in TextMessageView/
MediaMessageView (unconditional per-row writes under a message storm
tripped SwiftUI's "tried to update multiple times per frame" warning).
The restore-path bgRemaining=∞ log was already fixed on main (#1425
review follow-up 07b5ac31: init-time seed + sampler-routed restore
captures); verified, no change needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: multi-link disconnect guard, characteristic-aware retirement
Adversarial review of the duplicate-link PR (merge-with-nits):
- didDisconnectPeripheral gets the same multi-link guard as the
unsubscribe path: when a duplicate link drops naturally while the peer
stays live on another (dual-role central link, or a second bound link
during the post-restore consolidation window), peer-disconnect
bookkeeping (markDisconnected + disconnect notify) no longer runs — a
UI blip until the next announce re-marked the peer connected. The
reverse map was just repaired onto a connected survivor, so
directLinkState is the accurate probe. Scan restart and connect-slot
refill stay unguarded: they respond to the physical drop regardless of
remaining logical links. (No unit test: driving the CBCentralManager
delegate requires CBPeripheral instances, which cannot be constructed
in tests; the policy pieces backing the guard are covered.)
- Retirement is now characteristic-aware: the policy snapshot carries
characteristic presence, and keptPeripheralUUID selects anchors only
among writable links while any exist — consolidation must not keep a
link mid-service-rediscovery (didModifyServices cleared its
characteristic) and cancel the writable duplicate, stranding outbound
traffic on the central link until rediscovery finishes. When neither
anchor is writable but a writable duplicate exists, consolidation
defers to a later announce instead of guessing. The reverse-map
survivor repair in removePeripheral prefers writable links for the
same reason. Three new policy tests cover charless-vs-writable.
Deferred with code comments per review: central-side collapse keeps the
oldest subscription (no recency signal; remote consolidates within its
cooldown), and the extra preferred-bindings bleQueue hop in
sendOnAllLinks (fold into a combined snapshot if profiling flags it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fix restore-path main↔bleQueue deadlock and courier drop amplification
A device froze permanently in a two-phone test. Debugger stacks showed an
ABBA deadlock: the main actor was in bleQueue.sync (delivery-ack send →
broadcastPacket → readLinkState) while bleQueue was in main.sync
(captureBluetoothStatus reading backgroundTimeRemaining). The load that
lined the two edges up came from a courier-drop amplification storm:
drop dedup was in-memory only while the outbox driving 120s re-deposits
is persisted, so every relaunch republished the same undelivered DM as a
fresh 24h relay drop and every gateway relaunch re-fetched the whole
backlog — ~20 copies of one DM delivered in 40ms, each triggering
decrypt + delivery + ack + handshake work.
Fixes, in rank order:
- Edge B (P0): captureBluetoothStatus no longer main.syncs from bleQueue;
backgroundTimeRemaining is sampled on main and cached behind a lock.
Invariant documented: bleQueue must NEVER sync-dispatch to main.
- Edge A (P0, defense in depth): sendDeliveryAck / sendReadReceipt /
sendPrivateMessage / sendNoisePayload / triggerHandshake hop to
messageQueue like sendMessage, so no main-actor call path reaches
readLinkState's bleQueue.sync.
- Drop dedup (P1): publishedDropKeys and seenDropEventIDs persist across
relaunches (new BridgeDropDedupStore, entries expire with the 24h
NIP-40 drop window; wiped on panic) — one drop per message ID per 24h
regardless of relaunch count.
- Receiver dedup (P1): openCourierEnvelope dedups on the inner private
message ID before delivery, so a duplicate copy costs one decrypt and
never re-delivers, re-acks, or re-triggers a handshake.
- Handshake gating (P2): queued acks initiate a Noise handshake only for
reachable peers; mail from absent/rotated identities no longer turns
each copy into a mesh-wide handshake flood (the ack stays queued and
flushes when a session eventually establishes).
- Outbox (P3): re-enqueueing a queued message ID carries over its
depositedCourierKeys so resends stop re-burning the same courier slots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: offline-drop durability, gateway handoff retry, restore-log freshness, coalesced persist
Adversarial review of the storm/deadlock PR surfaced four issues:
- Offline blackhole (must-fix): a deposit made while relays were down
persisted its dedup key even though the drop only sat in the in-memory
pending queue — app killed before reconnect meant the relaunch lost the
drop but the persisted key blocked every re-deposit for 24h. The
persisted snapshot now excludes keys still pending; they become durable
only when flushPendingDrops actually publishes them.
- Gateway handoff: seen-event IDs were consumed before the deliverToPeer
handoff; a failed handoff (peer walked away) permanently dropped the
event for a single-gateway island. deliverToPeer now reports whether
the handoff was attempted, and a failure releases the seen slot so a
relaunch or backlog redelivery retries.
- Restore-path logs: central/peripheral-restore captures logged the init
sentinel bgRemaining=∞. The cache is now seeded in init's main-thread
branch and restore captures route through the sampler, which refreshes
the cached budget before logging.
- Persist cost: the dedup record was a full JSON encode + atomic write on
the main actor per mutation (once per event during a backlog re-fetch).
Writes now coalesce behind a 1s window, flushed immediately on
background/terminate; panic wipe stays immediate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove BoundedIDSet.remove, orphaned by the ExpiringIDSet migration
The drop-dedup sets that needed slot release moved to ExpiringIDSet;
remaining BoundedIDSet users only insert and check. Periphery caught it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool
The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.
Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
both callers release-and-reacquire instead of retargeting in place, and the
sheet releases its ref on dismissal.
The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only
Fixes the verified review findings on the tap-to-reveal PR:
- Permission dead-end: the "check for notes left here" hint now renders
only when location permission is already authorized (it never prompts).
Previously a location-denied install could tap it, flip the sticky
revealed flag, and get nothing for the rest of the session because
retarget() guards on authorization. Predicate lives on
NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
to the published permission state.
- Building-cell sampling: background geohash sampling subscribed
geo-sample-<gh> for every regional level including the precision-8
building cell, pre-reveal — contradicting the claim that nothing
building-precision hits a relay before the explicit act.
GeoChannelCoordinator now excludes the building level until
NearbyNotesCounter.revealed (injectable publisher for tests); coarser
levels keep the nearby-conversation hint and participant counts
working, and bookmarks stay exempt (bookmarking is explicit).
- Implicit reveal: opening the notices sheet no longer reveals — the
sheet auto-lands on the geo tab whenever a location channel is
selected, so browsing a remote geohash and opening notices revealed
the LOCAL building subscription. reveal() now fires only on the
person actively picking the geo segment, and only when the sheet has
a geo scope (the empty-mesh hint tap and /drop stay as before).
- Subscription hygiene: switching the sheet geo → mesh releases the
pooled notes manager (the REQ was left streaming behind the mesh
board); switching back re-acquires from the pool. The dismissal
release stays balanced — liveGeoManager is nil after the tab-switch
release.
- VoiceOver: the hint button exposes the plain localized action text
instead of the decorated "* 📍 … *" label.
- Removed LocationNotesManager.setGeohash: zero callers, and calling it
on a pooled instance would corrupt the pool's keying and refcounts.
New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline
CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.
Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut
Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".
Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
established Noise session; Nostr keeps its prompt-delivery predicate;
the protocol default forwards to canDeliverPromptly for transports
without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
it can deliver securely; otherwise it still sends (kicking the
handshake on a genuine link) but retains a copy and hands a sealed
copy to couriers, like the reachable path. flushOutbox gets the same
gate so a flush over an insecure link resends instead of dropping the
retained copy.
Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff
Review follow-ups on the canDeliverSecurely gate:
- flushOutbox no longer counts connected-but-insecure flushes toward the
maxSendAttempts drop: the message was actually transmitted over a live
link, so a peer whose Noise handshake stalls across reconnect flapping
must not burn through the cap and lose the store-and-forward copy the
gate exists to preserve. Retention stays bounded by the 24h outbox TTL
and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
for reachable-only (heuristic) sends. Regression test: >8 connected-
insecure flushes keep the retained copy, drop callback never fires.
- Document the known second-replay presence gap: linkBoundToOtherPeer
reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
the link, so once a first replay has rebound a link to an absent
victim's ID, a second replay marks the victim connected. Presence
display only — DMs stay on the retain+courier path via the router
gate. Not closed at the announce layer because the post-rebind state
is indistinguishable from a legitimate rotation/reconnect heal (a
supported, field-verified flow). Covered by a two-announce test.
- Note the accepted courier-spray metadata tradeoff at the connected-
insecure send: nearby verified peers receive a sealed copy (they learn
a DM exists, never its content), cleared on ack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery
Codex review follow-ups on 5017c232:
- P1: a legitimate rotation announce necessarily arrives on a link still
bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
new identity as disconnected — and the rebind only fixed link state,
never the registry. A healed rotation then read as disconnected until
the peer happened to announce again. rebindLinkAfterVerifiedDirect-
Announce now promotes the rebound identity to connected after the
containment checks pass (registry markConnected + topology/snapshot/
peer-list refresh; the .peerConnected UI event already fired from the
announce path). This consciously moves the forged-presence residue
from second-replay to first-successful-rebind — bounded by the rebind
containment (never steals a live identity, one rebind per link per
cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
Comments and the pinned tests updated; new regression test covers
rotate-on-open-link -> rebind -> isPeerConnected(new) == true.
- P2: BLEService.canDeliverSecurely probed the Noise session with the
peer ID as given, but sessions are keyed by the short wire ID — a send
keyed by the full 64-hex Noise key (favorites resolution) misread an
established session as insecure and needlessly retained + couriered
every DM until ack. Normalize with toShort() like isPeerConnected.
Test drives a real XX handshake and asserts both ID forms pass.
- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
flake fired again and slipped past its baselined USR (read exists in
loadFromDisk; the indexer intermittently misses it). Replace the
baseline entry with retain_codable_properties: true — deterministic,
and a truly-dead Codable field is a persisted-format change anyway.
Local periphery scan --strict: no unused code detected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* PTT hardening: burst-ID collision hijack + live-capture quota bypass
Fix C — burst-ID collision hijack: inbound live-voice assemblies and the
finished-burst registry were keyed by the sender-chosen burst ID alone, so
an attacker who observed a public burst ID could race a START and capture
the real talker's frames (packets from the true sender were dropped as
"collisions"). Assemblies now key on (peerID, scope, burstID): a colliding
START from another peer opens its own capped assembly and can never divert
the victim's frames; finalized-note absorption matches on the same triple,
preserving the sender/scope binding. Live capture files also gain the peer
ID in their name so colliding bursts land on distinct paths (still rejected
by burstID(fromVoiceFileName:), so live names remain unabsorbable).
Fix B — live-burst files bypassed the incoming-media quota: progressive
voice_live_*.aac captures were written via raw FileHandle without ever
touching BLEIncomingFileStore.enforceQuota, growing disk unbounded outside
the 100 MB LRU accounting. The coordinator now takes an injected
BLEIncomingFileStore, reserves pttMaxBurstBytes before opening a capture,
and sweeps orphaned voice_live_* partials from previous sessions at
startup. enforceQuota gains an `excluding:` set so in-flight partials are
never LRU-evicted mid-stream; existing callers are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Review fixes: pattern-guard live captures in quota, scope in capture names
Quota-eviction gap: BLEFileTransferHandler enforces the quota for every
finalized arrival via enforceQuota(reservingBytes:) with no exclusion set,
so a file landing at quota could LRU-evict an in-flight live capture —
unlinking the inode under the coordinator's open FileHandle and leaving a
dead bubble. Protection is now layer-independent: enforceQuota itself skips
voice_live_* names (they still count toward usage), and the excluding:
parameter is gone since the pattern guard covers its only caller. Orphaned
partials are still reclaimed by the coordinator's startup sweep, which the
quota deliberately never touches.
Same-peer cross-scope truncation: makeIncomingURL omitted the scope, so one
peer running a DM burst and a public burst with the same burst ID mapped
both assemblies onto one path — and FileManager.createFile truncates, so
each START corrupted the other capture. Names now mirror the assembly key:
voice_live_<burstHex>_<peerID>_<dm|mesh>.aac. The sweep and quota guard
match on the voice_live_ prefix and burstID(fromVoiceFileName:) still
rejects every live name, so live captures remain unabsorbable;
sameBurstIDCoexistsAcrossScopes now asserts both files survive with intact
contents.
Nits: the coordinator's file operations route through the store's
injectable FileManager instead of FileManager.default, and the
TestEnvironment.isRunningTests branch in init is replaced by a
sweepsOnInit parameter (tests sharing the real application-support
directory pass false for hermeticity).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Promote kept live captures off the voice_live_ name at finalize
Codex P1 follow-up: when a burst finalizes with frames but its .m4a note
never arrives, the voice_live_*.aac capture stays behind as the bubble's
replayable audio — yet the startup sweep deletes every voice_live_* file
and the quota guard skips them forever, so a kept fallback was both
quota-immune for the rest of the session and doomed at the next launch.
finalize now promotes the capture to a plain voice_ name (same suffix) and
republishes the row pointing at it; the finished-burst registry tracks the
promoted URL so a late note still absorbs and deletes it. voice_live_ is
thereby scoped to genuinely in-flight captures: the sweep never touches a
referenced fallback, and promoted files age out of the quota like any
finalized media. If the move fails the live name is kept — exactly the
pre-promotion behavior.
Premise correction, verified while tracing the reference model: chat rows
are NOT persisted across restarts (ConversationStore is in-memory; the
gossip archive replays MessageType.message packets only), so today's sweep
never orphaned a persisted row — the fix removes the in-session quota
immunity and makes the invariant hold if row persistence ever lands. Crash
case stays deliberate and documented: a mid-burst partial from a dead
process is swept because no surviving row can reference it.
Tests: finalize-as-fallback promotes the file, repoints the row, and
survives a later coordinator's startup sweep; promoted fallbacks are
LRU-evictable by the quota; the sweep still removes true orphans while
sparing promoted names; existing burst tests updated for the promoted
paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Bridge dedup keys on a content-derived stable mesh message ID
Public mesh messages carry no message ID on the BLE wire, so every
non-origin device minted a fresh UUID and the bridge's m-tag dedup only
matched on the origin device: duplicate bridged rows, misattributed
"across the bridge" counts, redundant downlink rebroadcasts, and an
m-tag spoof vector (an attacker could claim a victim's message ID).
Every device now derives the same stable ID from the signed wire fields
(sender ID + ms timestamp + trimmed content, SHA256/32 hex) via the new
MeshMessageIdentity — zero BLE wire change. The bridge event's m tag
carries the origin coordinates ["m", senderIDHex, timestampMs] and
receivers recompute the key from those plus the event's own content
instead of trusting a claimed ID; old-format/absent tags fall back to
the event ID as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix mixed-version message loss: m tag leads with the derived stable ID
The previous layout (["m", senderIDHex, timestampMs]) broke v1.7.0
receivers: their parser takes m[1] unconditionally as the timeline
dedup key whenever the tag has >= 2 elements, so every bridged message
from a new-version sender keyed on the CONSTANT sender hex and
inject-dedup dropped all but the first. The tag is now
["m", <derived stable ID>, senderIDHex, timestampMs]: old parsers get a
per-message-unique m[1] (exactly today's semantics), while the new
parser recomputes the ID from elements 2-3 plus the event's own content
and never trusts element 1, keeping the recompute-don't-trust property.
Also:
- Soften the overstated security claim in MeshMessageIdentity and the
BridgeService classify comment: forging a chosen ID onto different
content is infeasible, but all three hash inputs are cleartext on the
radio, so identical-content front-running by a radio-local attacker
remains possible (no worse than the unbridged mesh).
- Fix the stale archivedEchoKeys rationale: re-synced copies of others'
messages now carry the derived stable ID (insert-by-ID catches them);
the content key remains for echo--prefixed archive rows + self echoes.
- Tests: old-parser semantics on the new tag (m[1] per-message-unique
and equal to the derived ID) and a forged-m[1] event that cannot
pre-poison a genuine message's dedup slot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1
Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM
store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed,
well-scoped findings; deeper architectural/security items are tracked
separately.
- PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s
150ms retry pause left the mic live and streaming for up to 120s, because
cancel() no-op'd once `completed` was set. Bail after the sleep if the hold
was released, and make cancel() always tear down a late-started capture.
- Bridge courier depositDrop reported success and burned the dedup slot before
the drop was actually published (evicted/compose-fail = lying 📦 "carried"
with no retry). Only consume publishedDropKeys on durable accept; add
BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey).
- Blocked senders resurfaced via archived "heard here earlier" echoes, the one
path that bypassed the live block filter — filter at seed time.
- A late optimistic .sent clobbered the router's .carried state; extend
ConversationStore.shouldSkipStatusUpdate to a full precedence guard
(sending < sent < carried < delivered < read).
- Read receipts were permanently burned when the router dropped them (marked
sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only
record as sent on a successful route, else retry on the next read scan.
- MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a
peer that never reconnects sat on .sending until relaunch — run it in the
120s bridge sweep.
- Sightings tally now rolls over at midnight while idle; wave notification
action localized across all 29 locales; bridged anon#tag uses suffix(4) like
everything else; makeThrowawayIdentity delegates to NostrIdentity.generate();
.swiftlint.yml excludes .claude worktrees.
- Add regression tests: carried→sent no-downgrade, carried→delivered upgrade,
evicted pending drop stays retryable.
- Bump MARKETING_VERSION to 1.7.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage
The stricter no-downgrade guard (delivered/carried never regress to sent) broke
two things the earlier commit didn't catch locally (perf tests are skipped in
the default run, and Periphery runs only in CI):
- PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered
assuming both directions apply; the delivered -> sent half is now correctly
skipped, so the pass measured 0 updates. Alternate two delivered timestamps
instead — every update is real, no downgrade.
- Routing the geoDM "not in a location channel" error into the thread removed
the only caller of ChatPrivateConversationContext.addSystemMessage, leaving
it (and its mock) dead per Periphery. Drop the protocol requirement, the mock
impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is
compile-time enforced: the context can no longer emit a public system line).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge
- Read receipts: claim the receipt in sentReadReceipts synchronously
before spawning the routing task (chat open runs two read scans in one
MainActor stretch, so the async insert let every unread message route
twice), and release the claim when the route fails so the
retry-on-failed-route behavior is preserved.
- Delivery status: extend the no-downgrade guard so the `.sending`
stamp a pre-handshake resend emits can no longer clobber
carried/delivered/read (the 📦 indicator survived `.sent` but not
`.sending`).
- Archived echoes: blocking a peer now purges their carried public
messages from the gossip archive at block time (UnifiedPeerService
and /block), while the fingerprint-to-peerID mapping is still known —
the seed-time filter can't resolve offline non-favorite strangers and
stays only as defense-in-depth. New Transport hook (default no-op) +
GossipSyncManager.removePublicMessages with immediate persist.
- Bridge courier: an envelope that can't encode within the drop size
caps fails identically on every attempt; consume the dedup slot so
the 120s retry sweep stops re-running Noise sealing on it.
- MeshSightingsTracker: cache the day-key DateFormatter instead of
building one per call.
Tests: double-markAsRead dedup + failed-route retry, carried→sending
no-downgrade matrix, block-time purge (manager + service wiring),
oversize-drop slot consumption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Also skip the sent→sending downgrade in the delivery-status guard
Codex review follow-up: sendPrivateMessage without an established Noise
session emits `.sending` asynchronously, so it can land after the
message already reached `.sent` and visibly walk "Sent" back to
"Sending...". Treat `.sending` as weaker than `.sent` too — the status
was already truthful. `.failed` → `.sending` stays allowed so a retry
after a real failure remains visible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Retain Codable properties in Periphery scan (same fix as #1421)
The noiseKey assign-only false positive fired persistently on this branch
(twice, including a rerun) despite the baselined USR. Byte-identical to the
fix on fix/announce-replay-link-steal so the branches merge cleanly in
either order.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* DMs to unreachable peers route through store-and-forward; drop stale reachability gates
Field-found: sendPrivateMessage pre-judged reachability and marked the
message failed without ever calling the router, so the retained outbox,
courier deposits, and bridge drops never ran — a DM composed after a
peer's reachability window lapsed was dead on arrival while an identical
one sent a minute earlier delivered. Messages now always route; a live
path earns "sent", everything else stays "sending" until the router
reports carried/delivered or expires it as failed.
A sweep for sibling gates found and fixed:
- startPrivateChat refused offline non-mutual favorites ("mutual favorite
required for offline messaging") — store-and-forward only needs the
recipient's noise key, so the gate and its string are gone.
- markPrivateMessagesAsRead skipped the whole receipt pass for peers
without a stored Nostr key, starving mesh-connected non-favorites; the
router picks the transport now (sentReadReceipts dedups the parallel
PrivateChatManager path).
- DM/geoDM blocked notices and the unknown-group error posted to the
active public timeline; they now land in the thread they're about via
addLocalPrivateSystemMessage.
- Banned copy: literal "user" fallbacks in code become "anon" (the
default-nickname convention), and es/fr/it/pt translations of four keys
still saying usuario/utilisateur/utente/usuário now say person. Orphaned
keys removed (system.dm.unreachable, content.delivery.reason.unreachable,
system.chat.requires_favorite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Deflake BLEServiceCoreTests: longTimeout for positive waits
CI flaked on duplicatePacket_isDeduped: the first message landed after the
5s wait expired on a loaded runner (the test's later count==1 assertions
passed, proving late delivery, not lost delivery). All four positive waits
in the file now use TestConstants.longTimeout, which exists for exactly
this — waitUntil returns as soon as the condition holds, so passing runs
never pay the longer ceiling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix read-receipt test contamination; baseline Periphery noiseKey flake
Two CI failures, both environmental:
- markReadReceiptSent_returnsFalseOnSecondCall flaked because every test
ChatViewModel shared one per-process read-receipts scratch suite; the
lifecycle test persists the same "read-1" ID (a path the receipt-gate
removal now reaches), and test order decided who saw whose state. Each
instance now gets its own UUID-suffixed suite.
- Periphery intermittently misses the loadFromDisk read of
PrekeyBundleStore.StoredBundle.noiseKey and fails --strict with a false
"assign-only" finding (recurrence of a known flake). Its USR is
baselined; an in-source periphery:ignore can't work because strict mode
flags it as superfluous on runs where the indexer gets it right.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The macOS Debug configuration uses AppIconDebug, which had no mac-idiom
entries at all, so Debug builds shipped with the generic dock icon. And
the mac slots in AppIcon reused the full-bleed square iOS artwork; macOS
does not mask icons at render time, so even Release showed a sharp-
cornered square.
- Regenerate all AppIcon mac PNGs with the Big Sur icon grid baked in
(824x824 rounded-rect body on a transparent 1024 canvas + standard
drop shadow), derived from the same 1024 iOS art.
- Add a full mac icon set to AppIconDebug from the green debug artwork.
- Add scripts/generate-mac-appicon.swift (pure CoreGraphics) to
re-derive the mac sizes whenever the 1024 source changes.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Mesh bridging: stitch nearby mesh islands over Nostr, courier drops, settings surface
Three features plus a UI consolidation, all opt-in behind a new Bridge toggle:
Channel bridge: while bridging, outgoing public mesh messages are also
signed (with a derived, unlinkable per-cell Nostr identity) as kind-20000
events tagged #r with the local geohash-6 cell and published to the cell's
deterministic geo relays; mesh-only peers deposit via new toBridge/fromBridge
carrier directions through a bridge gateway (bridge + gateway toggles).
Remote islands' events render into the mesh timeline marked with a network
glyph. Events carry the original mesh message ID so the store's insert-by-ID
absorbs radio/bridge duplicates in either order. Loop prevention mirrors
GatewayService (three BoundedIDSet caches + skip-if-seen-locally + budgets).
Nothing crosses a bridge unless its author signed it for the bridge; a
per-message "nearby only" composer toggle keeps a message radio-only.
Courier over the bridge: sealed courier envelopes park on default relays as
kind-1401 drops tagged #x with their day-rotating recipient tag (NIP-40
expiry), signed by per-drop throwaway keys. Recipients subscribe for their
own candidate tags; bridge gateways watch verified local peers' tags and
hand matching drops over as directed courier packets. DM delivery to known
peers stops requiring a physical courier encounter; the Noise-X seal never
opens in transit.
Presence: kind-20001 heartbeats on the rendezvous feed a "people across the
bridge" count in the header (approximate: local participants subtracted by
radio-copy attribution).
Settings/Info: AppInfoView is now a segmented Settings/Info sheet. Settings
hosts appearance, voice (fixes the duplicated Voice section), a Connectivity
section (bridge + gateway + Tor toggles, the latter two moved out of the
location sheet), and a confirmed panic-wipe button. New announce TLV 0x06
advertises the gateway's rendezvous cell; PeerCapabilities gains .bridge.
i18n: 23 new keys across all 29 locales; coverage tests green.
Tests: 50 new app tests + 3 BitFoundation tests; full suite 1445 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Settings polish: one toggle style, sticky Info-first tab, location access in Settings
- The live-voice toggle now uses the same settings card + IRC pill as the
connectivity toggles (settingToggle, renamed from connectivityToggle).
- Segmented control orders Info first; the selected pane persists across
opens (AppStorage), so first-ever open lands on Info and afterwards the
sheet reopens where it was left.
- "remove location access" moved from the channels sheet into the Settings
Connectivity section (same key, still deep-links to system settings).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Location UX + copy: state-aware access control, honest empty state, clearer bridge/gateway text
- Settings' location control now covers all three permission states: grant
(real prompt, only possible while never-asked), open-system-settings when
denied, remove-access when granted. The channels sheet keeps its own grant
path for people who start there.
- The channels list no longer spins forever without permission; it shows
"grant location access to find nearby channels" instead (new key, 29
locales).
- Bridge and gateway subtitles rewritten for clarity; the gateway subtitle
moved to a new key since it now carries bridge traffic, and the old
geohash-only key is deleted. The word "user" is banned from copy in every
locale ("this person is blocked").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Field-test fixes: dedupe relay-connectivity triggers, throttle presence, log bridge decisions
First on-device run confirmed publishes accepted and the subscription
delivering, but exposed trigger spam: NostrRelayManager's isConnected
re-emits per relay recompute, so presence published 5x/second and the
courier-drop subscription rebuilt 6x in 300ms. removeDuplicates() on the
sinks + a 30s presence throttle (same-second heartbeats are byte-identical
events anyway). Also: injection/skip/downlink now log under 🌉 so field
verification is observable — the first test looked silent precisely because
dedup correctly suppressed same-island bridged copies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix restart self-echo: recognize own rendezvous events by derived pubkey
Second field run proved bidirectional bridging live (~1-2s Mac<->iPhone via
Tor) but caught a bug: relay backfill after an app relaunch re-delivered the
device's own pre-restart events, and with the in-memory published-ID cache
wiped they rendered as bridged copies of your own messages. The rendezvous
identity is deterministically derived per cell, so self-recognition by
pubkey needs no cache and survives restarts; own events are also marked
never-downlink. Regression test simulates the fresh-launch state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: mesh, bridge, and groups in one list
The header's bridged count had no matching faces anywhere — the people
sheet only knew mesh peers. BridgeService now publishes named participants
(nickname from message tags, geohash-style #last4 disambiguation, presence
keeps a known name alive) and the mesh people sheet gains an
"across the bridge" section between mesh peers and groups. Display-only
rows in v1 (bridged identities have no DM route yet). Two new catalog keys
x29 locales; also normalizes one out-of-sort-order entry inherited from a
hand-edited key on main.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Header: one people icon, one count, one sheet
Fold the bridged-people count into the main person.2.fill count instead of
a second network-glyph counter; the merged people sheet (mesh / across the
bridge / groups) is the breakdown. VoiceOver still announces how many of
the total are across the bridge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet symmetry: #mesh section header, no active count
Every section now gets the same glyph+label header shape (shared
PeopleSectionHeader): #mesh over the peer list, across-the-bridge over
bridged people. The "N active" line is gone (mesh); location channels keep
their geohash subtitle. Dead subtitle/count helpers removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* One switch: the bridge toggle drives all internet sharing
Field feedback: two toggles (bridge + gateway) with an invisible dependency
was a trap — bridged messages silently never reached mesh-only neighbors
unless a second lever was found and flipped. Collapsed to a single switch
that does the right thing for your situation:
- Bridge ON + internet: your messages cross, you see the bridge, AND your
device serves its island — accepts toBridge deposits, carries remote
messages onto the radio, watches courier drops for verified local peers,
advertises the cell, and runs the geohash-channel gateway.
- Bridge ON, no internet: you ride whoever nearby is serving.
- Bridge OFF: nothing of yours crosses; radio reception of bridged traffic
stays passive and free.
With every online bridger serving, downlink gets a 0.2-1.5s jittered
holdoff + send-time suppression recheck so co-located gateways don't burn
duplicate airtime (two-gateway test included). The internet-gateway card is
gone from Settings (GatewayService now follows the bridge switch, with
launch-time migration); its orphaned catalog keys deleted and the bridge
subtitle broadened across all 29 locales. Also: MeshPeerList's empty state
("nobody around...") aligned to the section row rhythm.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Bridge pumps its own location; fix centered people sheet; stop drop-subscription churn
Field session 3 found the bridge silently cell-less: it read
availableChannels passively, which only flow while some other feature
(channels sheet, location notes, geo sampling) happens to pump location —
turn those off and the bridge never gets a rendezvous. BridgeService now
requests a one-shot fix whenever it's enabled without a cell (and
piggybacks one on the presence timer so moving devices migrate cells).
Also from the session: the people sheet's scroll content hugged its widest
child and got centered on iPhone when the list was empty — pinned to full
width, leading. And the courier-drop subscription rebuilt every ~60s on
verified announces despite an unchanged tag set — now resubscribes only
when the tags actually change.
(Also merges origin/main: keychain test isolation #1413.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix launch race: bridge reacts to the permission callback, retries cell-less
Field session 4: the launch-time location request ran before the CoreLocation
authorization callback delivered, so refreshChannels() silently no-opped
(it requires .authorized) and nothing ever retried — the bridge stayed
cell-less all session. Three layers now close it:
- a $permissionState sink re-enters refreshRendezvous the moment
authorization resolves (the fast path),
- the maintenance timer arms even without a cell and retries the full
rendezvous refresh (the backstop; it previously required a cell, which
made it useless for exactly this failure),
- flipping the bridge switch while never-asked triggers the location
prompt — that's the user-initiated moment for it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: one header style for every section; bridged people visible while own bridge is off
GroupChatList's header now uses the shared PeopleSectionHeader (glyph +
label, same size/padding as #mesh and across-the-bridge; keeps its key and
header trait). Bridge section and the header count are no longer gated on
this device's own toggle: bridged people arrive over passive radio from a
serving neighbor, and whoever is visible in the timeline belongs in the
sheet and the count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: equalize the first section's gap
MeshPeerList's first row kept a legacy 10pt top bump from when nothing sat
above it, and the outer VStack's 6pt inter-child spacing applied between
the #mesh header and the list but not inside the other sections. Both gone:
sections own their rhythm (header 12/4, rows 4), spacing 0 outside.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: rows drop their leading glyphs — the section header carries the type
Mesh rows lose the per-state transport icon (connected/relayed/nostr/
offline), bridge rows the network glyph, group rows the person.3 icon.
Trailing state badges (star, lock, verified, unread, blocked, crown) stay,
and the row accessibility description still announces connection state, so
VoiceOver loses nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove dead code left behind by the Settings|Info consolidation
Periphery (--strict) flagged five leftovers on this branch:
- LocationChannelsModel.setGatewayEnabled: the standalone internet-gateway
toggle is gone (the bridge switch drives internet sharing), so nothing
calls it; the gatewayEnabled published property stays for the header dot.
- AppInfoView Strings.Location title/enable/openSettings: the old Location
section's header and permission buttons no longer exist. Their orphaned
Localizable.xcstrings entries go with them (the gateway-toggle keys were
already pruned).
- BridgePeopleList's appTheme environment value was never read.
The sixth CI finding (PrekeyBundleStore.StoredBundle.noiseKey assign-only)
is a Periphery flake: the property is read in loadFromDisk, the finding
didn't reproduce locally or on the next CI run of unchanged code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: mesh rows get their transport glyph back
The mesh section is the one heterogeneous list — the leading icon encodes
HOW a peer is reachable (radio / relayed / nostr-only / offline), which the
header can't say. Bridge and group rows stay glyph-free.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix courier drop skipped by stale BLE reachability; periodic deposit sweep
Field test: a DM sent seconds after the recipient's radio vanished still
saw them as "reachable" (60s verified retention), so canDeliverPromptly
held, every deposit was skipped, and the message sat spooled with no retry
path. MessageRouter now sweeps its outbox every 2 minutes and publishes
bridge drops for messages whose recipient no transport can promptly reach;
the drop layer's message-ID dedup makes the sweep idempotent. Regression
test reproduces the exact field sequence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Show "carried" when a message ships as a bridge drop
Field feedback: dropped DMs delivered but the sender saw nothing — the
drop path never fired onMessageCarried, and the recipient's delivery ack
has no radio route back until the peers next share a transport. depositDrop
now reports whether a fresh drop was sealed and the router marks the
message carried (📦) on both the send path and the sweep; the ack still
upgrades it to delivered whenever a route exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix locale word order: offline tag after name in DM header; localized "ago"
The private-chat header rendered the localized offline word in the
availability-glyph slot, before the name — "sin conexión bob". Offline now
shows the same dimmed person glyph the mesh list uses, with the word as a
small trailing tag after the name and lock, so it reads correctly in every
locale. Full sweep of views found one more composition bug: notice
timestamps glued English "ago" onto a localized duration; now the whole
phrase comes from RelativeDateTimeFormatter (same as the "fades" label).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* DM header offline state: icon only
The availability slot now reads uniformly as a glyph (radio / relayed /
globe / dimmed person), matching the mesh list; the text tag is gone.
VoiceOver still announces "offline" via the glyph's accessibility label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Offline glyph: slashed antenna instead of dimmed person
Offline is now the visual negation of connected (same antenna glyph,
slashed) in both the DM header and mesh list rows; a generic person icon
didn't say "unreachable".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>