Compare commits

..
Author SHA1 Message Date
a66c591f8e Courier: deposit in parallel when the only route is a send queue (#1368)
* Deposit with couriers in parallel when the only route is a send queue

The courier path was nearly unreachable: NostrTransport claims any
favorite with a known npub as "reachable" regardless of connectivity,
and the mesh favorite exchange shares npubs, so for essentially every
courier-eligible recipient the router picked Nostr's reachable branch.
With no internet the message just sat in the relay send queue — in the
flagship scenario (internet shutdown, mutual friend standing right
there) the courier walked away carrying nothing.

Add Transport.canDeliverPromptly(to:), defaulting to reachability for
radio-backed transports; NostrTransport answers honestly by mirroring
the relay manager's connection state (fail-closed behind Tor). When the
chosen transport can't hand the message off promptly, the router now
also deposits a sealed copy with connected couriers. Double delivery is
harmless: receivers dedup by message ID, and delivered/read acks never
downgrade the carried status. When relays are up, sends are trusted and
no courier quota is spent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Track DM-relay connectivity, not any-relay, for prompt delivery

Codex review: NostrRelayManager.isConnected is true when any relay is
up, including geohash/custom relays — but private messages target the
default (gift-wrap-capable) relay set and queue when none of those are
connected. A lone geohash relay would have suppressed the parallel
courier deposit while the DM sat in the queue. Publish a DM-scoped
connectivity flag and drive canDeliverPromptly from 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>
2026-07-06 17:45:57 +02:00
75da63c9d7 Fix favorites end-to-end: peer-list duplicates, Nostr sync, /fav key corruption (v1.5.4) (#1367)
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers

When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix courier handoff verification and directed sends

* Authenticate courier deposits by ingress peer

* Gate courier handover on direct announces and isolate store test

Envelopes are removed from the courier store optimistically, so releasing
them on a relayed (multi-hop) announce risks losing carried mail to a
speculative flood that never reaches the recipient. Handover now also
requires the announce to have arrived directly (full TTL), i.e. an actual
encounter with a live link; regression test builds a relayed copy of a
genuinely signed announce (TTL is excluded from announce signatures).

Also make CourierStore's on-disk location injectable so the persistence
test round-trips through a temp directory instead of wiping the real
Application Support store, and reattach BLEAnnounceHandler's doc comment
to the class it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use Xcode-bundled Swift in CI instead of a standalone toolchain

The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop couriered mail from blocked senders at envelope open

The UI-layer block check (isPeerBlocked in the transport event
coordinator) resolves a fingerprint from the live session or peer list,
but a couriered message arrives precisely when its sender is absent —
no session, no registry entry — so the check failed open and a blocked
identity's mail was delivered anyway. Gate in openCourierEnvelope,
where the sealed sender's full static key is in hand.

End-to-end test ferries a full deposit→carry→handover round and
verifies the envelope from a blocked sender never reaches the delegate
(confirmed failing without the gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption

- UnifiedPeerService: dedup offline favorites against mesh peers by noise
  key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs
  (never equal), leaving only a nickname+isConnected heuristic — a mutual
  favorite that was reachable-but-not-connected or renamed rendered twice,
  and a same-nick stranger could suppress a favorite entirely.
- Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live
  PM handler so they update theyFavoritedUs instead of rendering as chat
  text; mutual favorites can now form over Nostr. Delete the dead
  favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification
  (unwired, parsed a stale FAVORITE:TRUE|… format no sender emits).
- NostrTransport.isPeerReachable: match short form regardless of incoming
  ID width — toggling an offline favorite (addressed by 64-hex noise key)
  was silently dropped with no reachable transport.
- BLEService.sendPrivateMessage: normalize recipient to the short ID like
  sendFilePrivate, so a 64-hex target hits the existing Noise session
  instead of initiating a handshake with a 32-byte wire recipient ID.
- /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte
  routing ID for mesh peers — into the favorites store as a "noise key",
  and stop double-sending the favorite notification; delegate to
  toggleFavorite with a proper state check.
- FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored
  nickname when the caller passes the "Unknown" placeholder.
- Bump marketing version to 1.5.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline

Field-tested on device: with a DM window opened while the peer was on
mesh (conversation keyed by the short 16-hex ID), walking out of range
and sending failed instantly with "peer not reachable" even though the
header showed the peer as Nostr-reachable (mutual favorite, npub known).

sendPrivateMessage derived the favorites key as Data(hexString:
peerID.id) — for a short mesh ID that is the 8-byte routing ID, never
the noise key — so the mutual-favorite/Nostr-key checks always came up
empty and the send failed before reaching MessageRouter. Conversations
keyed by the full 64-hex noise-key ID (opened from the offline favorite
row) were unaffected, which is why later tests appeared to work.

Resolve the noise key properly (peerID.noiseKey, then the unified peer
row, then the favorites store by derived short ID) and add a regression
test for the mesh-keyed-peer-goes-offline case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Label Nostr DMs from favorites with their stored nickname

Field-tested: a DM delivered over the Nostr fallback rendered as
"anon#678e" instead of the sender's name. The inbound handler named the
sender via displayNameForNostrPubkey, which only knows geohash-scoped
names — even though the pipeline had already resolved the sender's
noise key (the conversation is keyed by it).

When the conversation key carries a noise key, prefer the favorite's
stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This
also stops an inbound Nostr [FAVORITED] from overwriting the stored
nickname with the anon fallback, since the same name feeds
updatePeerFavoritedUs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix courier path for offline favorites addressed by noise-key IDs

Two Codex review findings, both the same ID-width confusion this PR
targets, in the courier flow:

- CourierDirectory.favoritesBacked resolved recipients only via
  getFavoriteStatus(forPeerID:), which requires a short 16-hex ID —
  offline favorites are addressed by the full 64-hex noise-key ID, so
  attemptCourierDeposit silently bailed for exactly the peers couriers
  exist to serve. The 64-hex ID now yields its own key directly.
- openCourierEnvelope emitted the derived short mesh ID even when the
  sender has no live mesh identity, landing couriered mail in an
  unresolvable short-ID thread labeled "Unknown". Absent senders now
  emit the full noise-key ID so the message joins the stable favorite
  conversation; present senders keep the live short-ID thread.

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>
2026-07-06 17:17:21 +02:00
d285c6ad53 ux-fixes: lock alignment, caption band, wrapping, tap targets, VoiceOver, theme sweep (#1366)
* Fix lock glyph alignment, privacy-caption band, and empty-state wrapping

- Message-row locks: align to first text baseline instead of a hardcoded
  top padding that left the lock ~4pt below the line's visual center
- Header/caption locks: 1pt optical lift (lock.fill ink is bottom-heavy;
  geometric centering reads low); seal badge stays untouched
- DM privacy caption: sit on the themed surface like the rest of the
  bottom chrome instead of painting its own orange band
- Empty-state lines: non-breaking spaces so the closing * can't orphan
  and 'bitchat/ for help' can't break right after the slash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unify sheet close buttons, widen tiny tap targets, handle long nicknames

- New SheetCloseButton component: one glyph size/weight (13 semibold),
  32pt visual box, 44pt hit target; adopted by all 7 sheets (sizes had
  drifted across 12/13/14pt, two had no frame at all)
- Favorite star buttons get real tap targets (peer list + DM header)
- DM header nickname: single line with middle truncation instead of
  wrapping into the fixed-height header; peer-list names truncate tail
- Geohash people rows: leading glyph 12 -> 10 to match mesh rows
- Sidebar lock glyphs get the same optical lift as the DM header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make channel switching, voice notes, and header actions work under VoiceOver

- Channel rows in the location sheet are now single activatable buttons
  (label + selected trait + switch hint) with the bookmark toggle
  mirrored as a named accessibility action; bookmark buttons labeled
- Voice-note mic: press-and-hold drag gestures can't be activated by
  VoiceOver, so the default accessibility action now toggles
  start/stop-and-send; announces 'recording' state; localized labels
- Attachment button: camera (long-press) path exposed as a named
  action; labels localized instead of hardcoded English
- People-count button announces connected vs no-one-reachable (was
  color-only); verification QR button gains a spoken name (.help is
  only a hint on iOS); bitchat/ logo exposes its tap-for-app-info as a
  button (panic triple-tap stays undiscoverable on purpose)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Theme-correctness sweep: palette colors everywhere, AX-size header growth

- Fingerprint/verification sheet cards: palette-tinted boxes instead of
  fixed gray bands that ignored matrix green and occluded glass
- Voice-note card: palette background (translucent) instead of opaque
  white/black; waveform + payment chips + image placeholder follow suit
- .secondary/.primary/Color.blue swapped for palette.secondary/primary/
  accentBlue across location sheets, people sheets, message captions,
  and the header count (system gray read wrong under matrix green)
- Autocomplete/command rows: dropped the uniform gray wash that dulled
  the themed overlay panel
- 'tap to reveal' caption follows the theme font instead of hardcoding
  monospaced
- Headers use minHeight so two-line accessibility text sizes grow the
  bar instead of clipping inside it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix main header expanding to fill the screen

The header bar's fixed height was load-bearing: its children fill the
bar with .frame(maxHeight: .infinity) tap targets, so switching to an
open-ended minHeight let the header expand to swallow all available
vertical space, centering the title mid-screen and crushing the
timeline into the composer. Restore the fixed height — headerHeight is
a @ScaledMetric, so it already grows with Dynamic Type. Reproduced and
verified both layouts with an offscreen render harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* DM header: floating glass panel instead of muddy orange wash under glass

Orange at 14% over the backdrop gradient reads as a gray-beige band,
not a privacy signature. Under liquid glass the DM header now uses the
same floating chrome panel as the main header; the private signature is
already carried by the orange lock, caption, and composer accents.
Matrix keeps its orange wash over the opaque themed surface, unchanged.

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>
2026-07-06 14:22:38 +02:00
8296630cf3 Deflake app test suite: hermetic caches, robust async waits, perf-gate retry (#1365)
Four spurious CI failures on July 5, all loaded-runner flakiness:

- ViewSmokeTests.voiceAndMediaViews_renderAndWarmCaches asserted an exact
  bin count on WaveformCache.shared for the same URL the mounted
  VoiceNoteView was concurrently warming at its default 120-bin width;
  whichever barrier write landed last owned the entry. Probe the cache with
  a dedicated audio file no view touches, and purge both URLs. Also replace
  the fixed 250ms sleep for loadDuration's background hop with a waitUntil
  poll.

- sendImage_privateChatProcessesAndTransfersImage (and its sendVoiceNote /
  sendImage siblings) wait on work that hops through Task.detached; the
  global executor is shared with every parallel test worker, so a loaded
  runner can exceed the 5s wait. Raise those positive waits to
  TestConstants.longTimeout (10s) — waitUntil returns as soon as the
  condition holds, so passing runs are unaffected.

- subscribeNostrEvent_addsToTimeline_ifMatchesGeohash raced concurrently
  running suites (e.g. CommandProcessorTests) on the process-wide
  LocationChannelManager singleton: a mid-test channel flip reroutes or
  drops the event permanently, so no fixed wait recovers. The wait loop now
  re-asserts the channel and redelivers the event on each poll — idempotent
  because channel switches clear the processed-event set and the store
  dedups by message ID — so interference heals while genuine failures still
  time out.

- The performance floor gate failed on a saturated runner
  (gcs.buildAndDecode at 85% of floor). check-perf-floors.sh now re-runs
  the benchmark suite up to twice when a metric lands below floor,
  appending to the same PERF log and keeping each benchmark's best value
  across attempts: noise clears on a retry, a real algorithmic regression
  fails every attempt. Floors are unchanged and never lowered by the
  mechanism; missing-benchmark failures exit immediately without retrying.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:25:17 +02:00
c74e212ea3 Make peer lists accessible and actionable; block mesh peers by stable identity (#1360)
* Make peer lists accessible and actionable; block by stable identity

Who you can reach — the app's most important fact — was encoded in
unlabeled 10pt icons with macOS-only tooltips, and the mesh list had no
actions (block/favorite/verify were slash-command-only).

- Both peer lists become real accessibility citizens: each row is one
  element announcing name, reachability, and favorite/unread/blocked
  state, with a button trait and custom actions for the gesture-only
  interactions. Neither file previously had a single accessibility
  modifier. Reachability icons gain tooltips reusing existing strings;
  teleported vs in-area pins are explained.
- Mesh rows gain the context menu the geohash list already had: direct
  message, favorite, show fingerprint, block/unblock. The fingerprint
  double-tap, previously shadowed by the single tap, is reordered so it
  fires.
- The DM header's offline state (previously EmptyView — absence of a
  glyph as the only signal) becomes a dimmed "offline" tag, and a
  geohash DM — always Nostr-routed — no longer mislabels itself
  "offline".
- App Info gains a SYMBOLS legend defining every glyph the lists and
  headers use; nothing defined them before.
- Mesh block/unblock now resolve by the peer's stable Noise identity
  instead of a `/block <displayName>` string, so the exact tapped row is
  affected and offline peers can be unblocked (with covering tests).

New strings are added source-language (en) only.

* Surface block/unblock feedback in the conversation where it was triggered

setMeshPeerBlocked silently returned when the peer's identity could not
be resolved (e.g. long-press-blocking an old public message from a
sender who left and was never a favorite), where the /block command
printed "cannot block X: not found or unable to verify identity" — post
that same message from the guard branch.

Both the failure and confirmation messages now route through
addCommandOutput instead of addSystemMessage, so blocking from inside a
private chat prints into that chat rather than invisibly into the
public timeline (same routing #1363 applied to command output).

The confirmation also reuses the /block wording ("blocked X. you will
no longer receive messages from them") for parity with the command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove dead accessibility label and unreachable /unblock fallback

The favorite button's .accessibilityLabel in MeshPeerList is
unreachable: the row-level .accessibilityElement(children: .ignore)
swallows child elements, and the row's custom accessibility action
already covers favoriting.

ConversationUIModel.unblock is only called from the mesh peer list with
a non-optional mesh peerID, so the "/unblock <name>" fallback branch
could never run — take PeerID directly and drop the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:12:53 +02:00
7a0c821807 Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions (#1359)
* Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions

Three usability gaps in the message list, all presentation-layer:

- Empty timeline was a blank screen. It now narrates itself in dim,
  terminal-styled lines: what the channel is, that it's waiting for
  peers, and where the channel switcher and help live. Disappears with
  the first message.

- Scrolled up in a busy channel, nothing signalled that new messages
  arrived and there was no way back. A small "jump to latest" pill now
  appears while scrolled up, counting messages that arrived below, and
  taps back to the newest via the existing scroll helper. The unseen
  count re-baselines on channel switch so a cross-channel count delta is
  never shown as "new".

- A single tap anywhere on a message overwrote the composer draft with
  "@sender " and force-focused the field — casual taps while reading
  destroyed drafts. That whole-row tap is removed; mention/DM/hug/slap/
  block now live in the per-message context menu (reusing the handlers
  the existing action sheet already calls), and mention appends to the
  draft rather than replacing it. A failed own private message gets a
  resend item. The triple-tap-to-clear gesture gains a confirmation.

New strings are added source-language (en) only.

* Remove the failed original when resending a private message

Resend re-submitted the content but left the red failed bubble in
place, so every tap stacked another copy under it. Route resend
through ConversationUIModel, which drops the failed original from the
conversation store (removePrivateMessage) before sending the new copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Count only rendered human messages in the jump-to-latest pill

The unseen count was a raw delta of the messages array, so system
lines (join/leave narration) and whitespace-only messages that never
render as rows inflated the "N new" pill. Baseline the counters
against the number of messages that render as human message rows,
using the same predicates the row builder applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hide mention/DM context-menu actions inside 1:1 conversations

In a private conversation, mentioning the only other participant is
noise and the DM action just reopens the already-open conversation
(toggling the sidebar). Gate both behind privatePeer == nil so the
public-timeline context menu is unchanged; hug/slap/block/copy/resend
remain in DMs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:09:04 +02:00
0d251ad20c Require confirmation before deleting a received image; label media controls (#1358)
* Require confirmation before deleting a received image; label media controls

Double-tapping a received image permanently deleted the message and its
file — no confirmation, no undo — while double-tap is the most ingrained
photo gesture on mobile, and it raced the reveal tap via
`.exclusively(before:)`. A mesh may never re-deliver that image, so an
accidental double-tap can destroy the only copy.

- Remove the double-tap-to-delete gesture. Delete moves into a
  long-press context menu behind a confirmation dialog ("this cannot be
  undone — the sender may not be in range to send it again"), alongside
  explicit open and hide-image actions (the swipe-to-re-blur was
  undiscoverable). Taps now only reveal and open.
- The blur overlay says "tap to reveal" instead of a bare eye-slash.
- Add the first accessibility support to these media views: labeled
  image states (hidden/revealed/sending) with custom actions, labeled
  voice play/pause with the duration as the value, and labeled cancel
  buttons.

Delete remains available and its underlying behavior is unchanged — it's
just gated. New strings are added source-language (en) only.

* Expose the in-flight cancel button to VoiceOver

The image tile uses accessibilityElement(children: .ignore), which
collapses the whole subtree — including the visible cancel button shown
while a send is in flight — into one element. VoiceOver users could not
cancel an in-progress image send. Add a cancel accessibility action for
the sending state.

* Mark the accessibility delete action destructive too

The context-menu delete already uses role: .destructive; the matching
accessibility action did not. Make them consistent.

* Deduplicate image actions and align accessibility labels with convention

Extract the open/hide/delete button set shared by the context menu and
accessibilityActions into a single @ViewBuilder so the two can't drift.
Move the interaction hints out of the accessibility labels into
accessibilityHint (labels stay nouns; "tap to reveal" was wrong for
VoiceOver activation anyway), and rename the blurred-state action to
"reveal image" since it reveals rather than opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Offer cancel-send in the context menu while an image is sending

The context menu body was empty during sends, which some OS versions
still present as an empty preview. The accessibility path already
exposed a cancel-send action in that state; share the same button with
the context menu so pointer/touch users get a cancel path too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Label broken images honestly and drop actions that need the file

When the image file fails to load, the placeholder kept the "hidden
image"/"image" accessibility label with a reveal/open hint, and the
context menu still offered open/reveal on a URL that will not load.
Track the failed load, announce "image unavailable" with no interaction
hint, show a broken-photo glyph instead of an endless spinner, disable
the reveal/open gestures, and drop open/hide/reveal from the context
menu and accessibility actions -- keeping delete so received broken
attachments can still be cleaned up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:07:18 +02:00
c8ceac1968 Give private DMs an unmistakable visual signature (#1357)
* Give private DMs an unmistakable visual signature

An open DM renders identically to the public room — same view, same
green-on-black surface, with a small header name and two orange icons
as the only cues. For this audience the cost of misreading "am I in the
encrypted DM or the public channel?" is severe: sensitive text typed
into the wrong composer.

Four presentation-layer cues; no formatter or cache changes:

- The composer placeholder states the destination instead of a generic
  prompt: "message @jack — private" in a DM, "message #mesh — public,
  nearby" on mesh, "message #9q8yy — public" in a geohash channel.
- A persistent lock caption sits above the DM composer. It reads
  "private · end-to-end encrypted" only once the Noise session is
  actually secured or verified, and "private conversation" before that
  — the caption must not overstate encryption mid-handshake.
- The DM sheet header carries a faint orange wash (6%), extending the
  existing orange self-accent to the chrome.
- Each private message row is prefixed with a small orange lock glyph
  (view-layer, hidden from VoiceOver — the caption carries the
  semantic; the cached AttributedString formatter is untouched).

New strings are added source-language (en) only.

* Fix geohash-DM caption and placeholder

Two carve/review follow-ups:

- The privacy caption showed "private conversation" for geohash DMs,
  implying they are not encrypted — but geohash DMs are NIP-17
  gift-wrapped (always end-to-end encrypted), they just carry no Noise
  session status. Show the encrypted caption for geohash DMs and for
  secured Noise sessions; the pre-secured wording now applies only while
  a mesh handshake is still in progress.
- The private-chat placeholder prepended "@" to the partner name, which
  for a geohash DM (whose display name is already "#geohash/@name")
  produced a doubled "@". The "@" is now added only for mesh nicknames.

* Make the DM header orange wash visible in the matrix theme

The 6% orange background was chained after .themedSurface(), so in the
default matrix theme (whose themedSurface paints an opaque background)
the wash sat behind the surface and never rendered — it was only
visible in liquid glass. Apply the orange tint before .themedSurface()
so it layers in front of the themed background.

* Align DM lock glyph across text and media rows; keep header wash visible under glass

Media rows in a private conversation now get the same leading lock
glyph as text rows, so left edges line up instead of misaligning by
the glyph's width. The DM header's orange wash gets a higher opacity
under the liquid-glass theme, where themedSurface() adds no opaque
backing and 6% orange disappears into the backdrop gradient. Also
drops the dead sender != "system" guard in TextMessageView — system
messages are routed to systemMessageRow before this view is built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove orphaned content.input.message_placeholder from the string catalog

The destination-stating placeholders replaced its last code reference;
nothing on the branch resolves this key anymore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:06:18 +02:00
9ccff9cce4 Make private-message delivery status legible and accessible (#1356)
* Make private-message delivery status legible and accessible

The delivery indicator is the most stress-relevant signal in an
off-grid messenger, but it is hard to read:

- The status glyphs are 10pt icons whose only explanation is a
  `.help()` tooltip, which does not exist on iOS.
- Delivered vs read is the same double-checkmark distinguished only by
  colour.
- No case carries an accessibility label, so VoiceOver announces
  nothing.
- Two failure reasons ("Not delivered", "Encryption failed") bypass the
  localized reason catalog and are hardcoded English.

Changes (presentation only; the DeliveryStatus enum and the
contract-tested `displayText` are untouched):

- Add `DeliveryStatus.bitchatDescription`, a localized app-layer
  description, used as the macOS tooltip, a VoiceOver label on every
  status glyph, and — on iOS, where tooltips don't exist — a
  tap-to-reveal caption under the message.
- Failure reasons stay visible as a red caption without a tap.
- Read vs delivered is now legible without colour: read uses
  filled-circle checkmarks.
- Route the two hardcoded failure reasons through the localized catalog.

New strings are added source-language (en) only.

* Show the failure reason on failed media messages too

TextMessageView gained a visible red failure caption (the status
glyph's .help() tooltip does not exist on iOS), but MediaMessageView
still rendered the bare glyph — so a failed voice-note or image send
showed only a 10pt red triangle with no reason on iOS. Add the same
failure caption to media messages.

* Collapse revealed delivery detail when the status changes

A caption revealed while a message was "sending" stayed open and
silently morphed through later statuses (sent, delivered, read).
Reset showDeliveryDetail when the snapshotted DeliveryStatus changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add tap-to-reveal delivery detail to media rows

Media rows showed the same delivery glyphs as text rows but offered no
way to explain them on iOS, where .help() tooltips don't exist. Mirror
the text-row pattern: the glyph is now a button that reveals the
localized status caption below the header, failure reasons stay
visible without a tap, and the revealed caption collapses when the
status advances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Localize the remaining voice-note failure reasons

ChatMediaTransferCoordinator still passed hardcoded English reasons
into .failed(reason:), which now surface verbatim in the always-visible
failure caption. Route them through String(localized:) under the
existing content.delivery.reason.* convention.

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>
2026-07-06 09:58:25 +02:00
66536063ca Route command output to the conversation where the command was typed (#1363)
CommandProcessor results (/help text, errors like "unknown command",
/msg confirmations) were always appended to the public timeline via
addSystemMessage, so a command typed inside a DM appeared to do
nothing until the user switched back to the public channel.

handleCommand now routes .success/.error output to the open private
chat when one is selected, falling back to the public timeline
otherwise. The DM selection is read after processing so commands that
switch chats (/msg) print into the conversation they just opened.

Follow-up to #1354, which added /help and surfaced this routing gap.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:26:31 +02:00
Hot Pixel GroupandGitHub e191e9c6f2 Fix slash-command suggestions that insert commands the processor rejects (#1354)
The autocomplete panel is the only in-app surface for discovering slash
commands, but several suggestions do not match what CommandProcessor
accepts, so tapping them inserts a command that returns "unknown
command":

- CommandInfo suggests /dm, /favorite, /unfavorite, but the processor
  only handles /m, /msg, /fav, /unfav. Aliases are aligned to the
  accepted spellings (msg, fav, unfav).
- Favorites are suggested only in geohash contexts (isGeoPublic ||
  isGeoDM) — exactly where the processor rejects them ("favorites are
  only for mesh peers"). The gating is inverted so they appear in mesh,
  where they work.

Also, small related fixes to the discovery surface:
- /help is now handled (the ChatViewModel command docstring already
  claimed it existed); it prints a local system line listing the valid
  commands, and the unknown-command error points at it.
- The suggestion panel keeps the matched command's usage row (e.g.
  "/msg <nickname>") visible while arguments are typed, instead of
  vanishing at the first space; in that mode the row is informational
  and no longer overwrites the draft on tap.

New string is added source-language (en) only. The CommandInfo contract
test is updated to the corrected metadata.
2026-07-05 14:15:57 +02:00
b31a63ce37 Burn down SwiftLint advisory violations from 109 to 4 (#1362)
Mechanical style fixes across the enabled rule set, mostly via
swiftlint --fix (trailing_comma, comma, colon, trailing_newline,
comment_spacing, unused_closure_parameter, unneeded_break_in_switch,
opening_brace) plus hand fixes:

- non_optional_string_data_conversion (45): .data(using: .utf8)! and
  ?? Data() fallbacks replaced with the non-optional Data(_.utf8),
  including two production sites (NIP-44 HKDF info constant and the
  announce canonicalization context/nickname bytes — byte-identical
  output, only the impossible-nil handling is gone).
- switch_case_alignment: LocationChannel had a misindented closing
  brace; also repaired an --fix artifact in BLEService's .none case.
- redundant_string_enum_value: TrustLevel raw values equal to the case
  names (encoded form unchanged).
- unused_optional_binding: let _ = binds replaced with != nil / is Bool.
- static_over_final_class: PreviewView.layerClass.
- Resolved the BinaryProtocolTests TODO by documenting that 8-byte
  recipient ID truncation is the fixed wire-field size, not a bug.

The 4 remaining violations are all todo markers for a shared
test-helpers module (tracked in #1088) and one Reuse note.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:09:13 +02:00
0f26a27980 Add SwiftLint as an advisory CI-only lint job (no Xcode plugin dependency) (#1361)
* Add SwiftLint as an advisory CI-only lint job (no Xcode plugin dependency)

* Harden the advisory lint job and exclude build dirs from local runs

The lint job runs a third-party container image, so drop its token to
read-only, stop actions/checkout from persisting credentials into the
workspace the container can read, and pin the image by digest as well
as tag (tags are mutable). Also add an excluded: list to .swiftlint.yml
so local swiftlint runs don't drown in .build/DerivedData artifacts —
CI checkouts are fresh, so this only affects working trees.

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>
2026-07-05 13:36:59 +02:00
105 changed files with 5150 additions and 1420 deletions
+34 -2
View File
@@ -12,7 +12,10 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute # A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue). # default (observed: intermittent app-suite hangs starving the queue).
timeout-minutes: 15 # The long steps carry tighter individual bounds (5-minute test watchdog,
# 6-minute benchmark step, 10-minute floor gate that may re-run the
# benchmarks up to twice on a noisy runner); this is the backstop.
timeout-minutes: 25
strategy: strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -102,9 +105,14 @@ jobs:
# Order-of-magnitude performance regression gate. Floors are deliberately # Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this # generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance. # catches algorithmic regressions, never runner variance. If a metric
# still lands below floor (a saturated runner can dip one), the script
# re-runs the benchmarks — appending to the same log and keeping each
# benchmark's best value per metric — so noise clears on retry while a
# real regression fails every attempt. Floors are never lowered by this.
- name: Performance floor gate - name: Performance floor gate
if: matrix.name == 'app' if: matrix.name == 'app'
timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the # Informational only: surfaces per-file and total line coverage in the
@@ -145,3 +153,27 @@ jobs:
ARCHS=arm64 \ ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
build build
# Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
# it can never break the documented xcodebuild path or block a merge.
lint:
name: SwiftLint (advisory)
runs-on: ubuntu-latest
timeout-minutes: 15
# This job runs a third-party container image, so give it the least
# privilege we can: a read-only token, and no credentials left in the
# checkout for the container to find.
permissions:
contents: read
container:
# Tag for readability, digest for immutability (tags can be repointed).
# Bump both together, deliberately — never a floating tag.
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
continue-on-error: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging
+1
View File
@@ -6,6 +6,7 @@
plans/ plans/
## AI ## AI
CLAUDE.md
AGENTS.md AGENTS.md
.claude/ .claude/
+25 -43
View File
@@ -1,51 +1,33 @@
# SwiftLint configuration for BitChat. # Build artifacts and generated sources; keeps local `swiftlint` runs clean
# # (CI checkouts are fresh, so this only matters in a working tree).
# Intentionally pragmatic: rules that would flood the existing codebase are
# disabled so the lint signal stays actionable; re-enable them individually as
# files get cleaned up. Not wired into CI yet — run locally with `swiftlint`
# from the repo root.
included:
- bitchat
- bitchatTests
- localPackages/BitFoundation/Sources
- localPackages/BitFoundation/Tests
- localPackages/BitLogger/Sources
excluded: excluded:
- .build - .build
- localPackages/Arti - .swiftpm
- localPackages/BitFoundation/.build - .DerivedData
- localPackages/BitLogger/.build - DerivedData
- build
- localPackages/*/.build
disabled_rules: disabled_rules:
# Style/volume rules that currently produce noise across the codebase.
- line_length - line_length
- identifier_name
- type_name - type_name
- type_body_length - identifier_name
- cyclomatic_complexity
- function_parameter_count
- large_tuple
- nesting
- todo
- trailing_comma
- opening_brace
- statement_position - statement_position
- for_where - implicit_optional_initialization
- redundant_string_enum_value
- non_optional_string_data_conversion
# Common in tests (force casts/tries on fixtures).
- force_cast
- force_try - force_try
- vertical_whitespace
file_length: - for_where
warning: 600 - control_statement
# BLEService.swift is currently ~3,500 lines; the error threshold sits above - void_function_in_ternary
# today's maximum so the codebase passes as-is and only future growth of the - redundant_discardable_let # SwiftUI breaks without it
# largest files trips it. # To be enabled as we fix the issues
error: 4000 - trailing_whitespace
- cyclomatic_complexity
function_body_length: - function_body_length
warning: 100 - function_parameter_count
error: 200 - type_body_length
- file_length
- large_tuple
- force_cast
- multiple_closures_with_trailing_closure
- nesting
-106
View File
@@ -1,106 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands
```bash
# Build macOS (no signing)
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build
# Build iOS for simulator
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
# Run all tests (iOS simulator)
xcodebuild -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test
# Run tests via Swift Package Manager (used in CI)
swift build && swift test --parallel
# Clean build
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" clean
# Quick dev build and run (macOS only, requires `just`)
just run
```
### Running Specific Tests
```bash
# Run a single test class
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/IntegrationTests
# Run a single test method
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/NoiseProtocolTests/testHandshake
```
## Architecture Overview
BitChat is a **dual-transport P2P messaging app**: Bluetooth mesh for offline local communication, Nostr protocol for internet-based global messaging.
### Local Packages (`localPackages/`)
- **BitFoundation**: Shared foundation types — `BinaryProtocol` (compact binary packet format for BLE), `BitchatPacket`, `BitchatMessage`, `PeerID`, hex/SHA256/compression utilities. Has its own test suite under `localPackages/BitFoundation/Tests` (run `swift test` inside the package).
- **BitLogger**: `SecureLogger` logging.
- **Arti**: Tor integration (exposes the `Tor` product).
### Transport Layer
- **BLEService** (`bitchat/Services/BLE/BLEService.swift`): Core Bluetooth LE mesh networking - peer discovery, connection management, multi-hop relay (max 7 hops), packet fragmentation. The BLE directory contains ~40 focused collaborators (handlers, policies, buffers): `BLEReceivePipeline` dispatches inbound packets to `BLEAnnounceHandler` / `BLENoisePacketHandler` / `BLEPublicMessageHandler` / `BLEFragmentHandler` etc.; outbound planning lives in the `BLEOutbound*` types; peer state in `BLEPeerRegistry`.
- **NostrTransport** (`bitchat/Services/NostrTransport.swift`): Internet transport via Nostr relays with NIP-17 encryption
- **Transport protocol** (`bitchat/Services/Transport.swift`): Common interface both transports implement
### Encryption Layer
- **NoiseProtocol** (`bitchat/Noise/`): Noise XX pattern for end-to-end encryption with forward secrecy
- `NoiseEncryptionService`: Main encryption/decryption API
- `NoiseSessionManager`: Thread-safe per-peer session management
- `NoiseSession`: Individual peer session state (handshake, send/receive ciphers)
- **NostrProtocol** (`bitchat/Nostr/NostrProtocol.swift`): NIP-17 gift-wrapped encryption for Nostr messages
### Protocol Layer
- **BinaryProtocol** (`localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift`): Compact binary packet format for BLE (lives in BitFoundation, not `bitchat/Protocols/`)
- **BitchatProtocol** (`bitchat/Protocols/BitchatProtocol.swift`): Message types and packet structures
- **LocationChannel/Geohash** (`bitchat/Protocols/`): Geographic channel routing
### Application Layer
- **AppRuntime** (`bitchat/App/AppRuntime.swift`): Composition root. Owns `ChatViewModel`, `ConversationStore`, the feature models (`PublicChatModel`, `PeerListModel`, `LocationPresenceStore`, `PeerIdentityStore`, ...), and the app event stream.
- **ConversationStore** (`bitchat/App/ConversationStore.swift`): Single-writer, single source of truth for conversation message state and selection (see `docs/CONVERSATION-STORE-DESIGN.md`). Feature models and `ChatViewModel` observe it and mutate it through its intent API.
- **ChatViewModel** (`bitchat/ViewModels/ChatViewModel.swift`, ~1,600 lines): Central coordinator that wires and delegates to ~25 focused coordinator/pipeline types in `bitchat/ViewModels/`, e.g.:
- `ChatPrivateConversationCoordinator` / `ChatPublicConversationCoordinator`: DM and public chat flows
- `ChatNostrCoordinator``GeohashSubscriptionManager`, `NostrInboundPipeline`, `GeoPresenceTracker`, `GeoChannelCoordinator`: Nostr/geohash channel logic
- `ChatOutgoingCoordinator`, `ChatDeliveryCoordinator`, `PublicMessagePipeline`: send paths and delivery tracking
- `ChatMediaTransferCoordinator`, `ChatMediaPreparation`: media transfers
- `ChatLifecycleCoordinator`, `ChatTransportEventCoordinator`, `ChatPeerListCoordinator`, `ChatPeerIdentityCoordinator`, `ChatVerificationCoordinator`: lifecycle, transport events, peer state
- `bitchat/ViewModels/Extensions/` (`ChatViewModel+Nostr/+PrivateChat/+Tor`): thin delegation shims kept for call-site stability; the real logic lives in the coordinators
- **MessageRouter** (`bitchat/Services/MessageRouter.swift`): Intelligent transport selection (BLE → Nostr fallback)
- **PrivateChatManager** (`bitchat/Services/PrivateChatManager.swift`): DM session management
## Test Infrastructure
Tests use an **in-memory networking harness** for deterministic, race-free testing:
- **MockBLEService** (`bitchatTests/Mocks/MockBLEService.swift`): Simulated BLE mesh with configurable topology
- `MockBLEService.resetTestBus()` - Clear state in setUp()
- `simulateConnectedPeer(_:)` / `simulateDisconnectedPeer(_:)` - Configure topology
- `autoFloodEnabled` - Enable broadcast flooding for Integration tests only
- **Test categories**:
- `bitchatTests/EndToEnd/`: Full message flow tests with explicit routing
- `bitchatTests/Integration/`: Multi-node topology tests with auto-flooding
- Unit tests: Individual component tests
## Key Patterns
- **Threading**: Use `@MainActor` for UI, `Task { @MainActor in ... }` for main thread dispatch
- **Delegation**: `BitchatDelegate`, `TransportPeerEventsDelegate` for event propagation
- **Session recovery**: On decrypt failure, clear local session and re-initiate Noise handshake (no NACK)
## Device Setup
To run on physical devices:
1. Copy `Configs/Local.xcconfig.example` to `Configs/Local.xcconfig`
2. Add your Developer Team ID to `Local.xcconfig`
3. Replace `group.chat.bitchat` with `group.<your_bundle_id>` in entitlements
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.3 MARKETING_VERSION = 1.5.4
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+2 -2
View File
@@ -13,9 +13,9 @@ let package = Package(
.executable( .executable(
name: "bitchat", name: "bitchat",
targets: ["bitchat"] targets: ["bitchat"]
), )
], ],
dependencies:[ dependencies: [
.package(path: "localPackages/Arti"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
+4 -4
View File
@@ -561,7 +561,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.3; MARKETING_VERSION = 1.5.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -620,7 +620,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.3; MARKETING_VERSION = 1.5.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -655,7 +655,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3; MARKETING_VERSION = 1.5.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -749,7 +749,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3; MARKETING_VERSION = 1.5.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
+20
View File
@@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage(message) chatViewModel.sendMessage(message)
} }
/// Resends a failed private message through the normal send path,
/// removing the failed original so the re-submission replaces it
/// instead of stacking a duplicate under the red bubble.
func resendFailedPrivateMessage(_ message: BitchatMessage) {
chatViewModel.removePrivateMessage(withID: message.id)
chatViewModel.sendMessage(message.content)
}
func clearCurrentConversation() { func clearCurrentConversation() {
chatViewModel.sendMessage("/clear") chatViewModel.sendMessage("/clear")
} }
@@ -67,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
if let peerID, peerID.isGeoChat, if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) { let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName) chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
// Mesh: block the peer's stable Noise identity resolved from the
// tapped peerID rather than re-resolving a display-name string.
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
} else { } else {
chatViewModel.sendMessage("/block \(displayName)") chatViewModel.sendMessage("/block \(displayName)")
} }
} }
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
/// the tapped peer's stable identity so the exact row is unblocked this
/// also works for offline peers, which the `/unblock <displayName>` command
/// cannot resolve.
func unblock(peerID: PeerID, displayName: String) {
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
}
func updateAutocomplete(for text: String, cursorPosition: Int) { func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
} }
+7 -1
View File
@@ -232,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
let availability = resolveAvailability(for: headerPeerID, peer: peer) // Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil ? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID) : chatViewModel.getEncryptionStatus(for: headerPeerID)
+1 -1
View File
@@ -71,7 +71,7 @@ struct BitchatApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime? weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
true true
} }
+4 -4
View File
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
} }
enum TrustLevel: String, Codable { enum TrustLevel: String, Codable {
case unknown = "unknown" case unknown
case casual = "casual" case casual
case trusted = "trusted" case trusted
case verified = "verified" case verified
} }
// MARK: - Identity Cache // MARK: - Identity Cache
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -11,33 +11,38 @@ import Foundation
// MARK: - CommandInfo Enum // MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable { enum CommandInfo: String, Identifiable {
// Raw values must match the aliases CommandProcessor actually accepts
// the suggestion panel is the app's only command-discovery surface, and
// suggesting a spelling the processor rejects teaches users dead ends.
case block case block
case clear case clear
case help
case hug case hug
case message = "dm" case message = "msg"
case slap case slap
case unblock case unblock
case who case who
case favorite case favorite = "fav"
case unfavorite case unfavorite = "unfav"
var id: String { rawValue } var id: String { rawValue }
var alias: String { "/" + rawValue } var alias: String { "/" + rawValue }
var placeholder: String? { var placeholder: String? {
switch self { switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">" return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who: case .clear, .help, .who:
return nil return nil
} }
} }
var description: String { var description: String {
switch self { switch self {
case .block: String(localized: "content.commands.block") case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear") case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug") case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message") case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap") case .slap: String(localized: "content.commands.slap")
@@ -47,12 +52,14 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite") case .unfavorite: String(localized: "content.commands.unfavorite")
} }
} }
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who] let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM { if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite] return baseCommands
} }
return baseCommands return baseCommands + [.favorite, .unfavorite]
} }
} }
+7 -1
View File
@@ -93,6 +93,7 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key case IK // Initiator knows responder's static key
case NK // Anonymous initiator case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
} }
enum NoiseRole { enum NoiseRole {
@@ -601,7 +602,7 @@ final class NoiseHandshakeState {
switch pattern { switch pattern {
case .XX: case .XX:
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK, .X:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic { } else if role == .responder, let localStatic = localStaticPublic {
@@ -904,6 +905,7 @@ extension NoisePattern {
case .XX: return "XX" case .XX: return "XX"
case .IK: return "IK" case .IK: return "IK"
case .NK: return "NK" case .NK: return "NK"
case .X: return "X"
} }
} }
@@ -925,6 +927,10 @@ extension NoisePattern {
[.e, .es], // -> e, es [.e, .es], // -> e, es
[.e, .ee] // <- e, ee [.e, .ee] // <- e, ee
] ]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
} }
} }
} }
+26 -118
View File
@@ -328,85 +328,52 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict) return try NostrEvent(from: rumorDict)
} }
// MARK: - Encryption (NIP-44 v2/v3) // MARK: - Encryption (NIP-44 v2)
/// Whether outgoing DMs use the padded "v3:" envelope. private static func encrypt(
///
/// TWO-PHASE ROLLOUT DO NOT FLIP YET. Deployed clients hard-reject any
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
/// smuggled inside "v2:" without breaking old receivers either. Enabling
/// this today would strand every client in the field.
///
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
/// Phase 2 (future release, once phase-1 clients are widely deployed):
/// set this to true so outgoing DMs stop leaking plaintext length to
/// relays.
static let sendPaddedEnvelope = false
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func encrypt(
plaintext: String, plaintext: String,
recipientPubkey: String, recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey, senderKey: P256K.Schnorr.PrivateKey
padded: Bool = NostrProtocol.sendPaddedEnvelope
) throws -> String { ) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message (XChaCha20-Poly1305, versioned envelope) // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret // Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info). // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
// The v3 envelope deliberately reuses the same key derivation; it only
// changes the payload framing (length prefix + padding).
let key = try deriveNIP44V2Key(from: sharedSecret) let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305 // 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24) var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in _ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
} }
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
let pt = Data(plaintext.utf8) let pt = Data(plaintext.utf8)
let payload = padded ? try NIP44Padding.pad(pt) : pt let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
// version prefix + base64url(nonce24 || ciphertext || tag)
var combined = Data() var combined = Data()
combined.append(nonce24) combined.append(nonce24)
combined.append(sealed.ciphertext) combined.append(sealed.ciphertext)
combined.append(sealed.tag) combined.append(sealed.tag)
return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined) return "v2:" + Base64URLCoding.encode(combined)
} }
/// Internal (rather than private) so tests can exercise both envelope private static func decrypt(
/// versions directly.
static func decrypt(
ciphertext: String, ciphertext: String,
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:" // Expect NIP-44 v2 format
// envelope (see `sendPaddedEnvelope` for the rollout plan). guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let isPadded: Bool
if ciphertext.hasPrefix("v2:") {
isPadded = false
} else if ciphertext.hasPrefix("v3:") {
isPadded = true
} else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(3)) let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded), guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16), data.count > (24 + 16),
@@ -432,23 +399,18 @@ struct NostrProtocol {
} }
// If 32 bytes (x-only) try both parities, otherwise single try // If 32 bytes (x-only) try both parities, otherwise single try
let payload: Data
if senderPubkeyData.count == 32 { if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) { if let pt = try? attemptDecrypt(using: even) {
payload = pt return String(data: pt, encoding: .utf8) ?? ""
} else {
let odd = Data([0x03]) + senderPubkeyData
payload = try attemptDecrypt(using: odd)
} }
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else { } else {
payload = try attemptDecrypt(using: senderPubkeyData) let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
} }
// The AEAD tag has already authenticated the payload; unpadding
// failures here mean a malformed sender, not a wrong key.
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
return String(data: plaintextData, encoding: .utf8) ?? ""
} }
private static func deriveSharedSecret( private static func deriveSharedSecret(
@@ -679,60 +641,6 @@ enum NostrError: Error {
case encryptionFailed case encryptionFailed
} }
// MARK: - NIP-44 style padding (v3 envelope payload framing)
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
/// total is padded to `paddedLength(for:)` power-of-two-derived buckets with
/// a 32-byte minimum so ciphertext length no longer reveals exact plaintext
/// length to relays.
enum NIP44Padding {
static let minPaddedLength = 32
static let maxPlaintextLength = 65535
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
/// granularity of max(32, nextPowerOfTwo/8).
static func paddedLength(for unpaddedLength: Int) -> Int {
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
// Smallest power of two strictly greater than (unpaddedLength - 1).
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
let chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * ((unpaddedLength - 1) / chunk + 1)
}
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
/// fit the 16-bit length prefix.
static func pad(_ plaintext: Data) throws -> Data {
let length = plaintext.count
guard length >= 1, length <= maxPlaintextLength else {
throw NostrError.encryptionFailed
}
let padded = paddedLength(for: length)
var result = Data(capacity: 2 + padded)
result.append(UInt8(length >> 8))
result.append(UInt8(length & 0xFF))
result.append(plaintext)
result.append(Data(count: padded - length))
return result
}
/// Read the 2-byte length prefix, validate the total padded size matches
/// it exactly, and return the plaintext. Throws on any inconsistency so a
/// malformed (already-authenticated) payload can never over- or
/// under-read.
static func unpad(_ padded: Data) throws -> Data {
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
let start = padded.startIndex
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
guard length >= 1,
padded.count == 2 + paddedLength(for: length) else {
throw NostrError.invalidCiphertext
}
return padded.subdata(in: (start + 2)..<(start + 2 + length))
}
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) // MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol { private extension NostrProtocol {
@@ -740,7 +648,7 @@ private extension NostrProtocol {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(), salt: Data(),
info: "nip44-v2".data(using: .utf8)!, info: Data("nip44-v2".utf8),
outputByteCount: 32 outputByteCount: 32
) )
return derivedKey.withUnsafeBytes { Data($0) } return derivedKey.withUnsafeBytes { Data($0) }
+7
View File
@@ -137,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs
/// target the default (gift-wrap-capable) relay set, so a connected
/// geohash/custom relay alone must not count sends would still queue.
@Published private(set) var isDMRelayConnected = false
private let dependencies: NostrRelayManagerDependencies private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false private var allowDefaultRelays: Bool = false
@@ -1087,6 +1091,9 @@ final class NostrRelayManager: ObservableObject {
private func updateConnectionStatus() { private func updateConnectionStatus() {
isConnected = relays.contains { $0.isConnected } isConnected = relays.contains { $0.isConnected }
// Relay URLs are normalized before entries are created, so direct
// set membership is sound.
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
} }
/// A relay that drops before sending EOSE must not stall initial-load /// A relay that drops before sending EOSE must not stall initial-load
@@ -132,4 +132,3 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes) replaceSubrange(offset..<(offset+4), with: bytes)
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5 case .city: return 5
case .province: return 4 case .province: return 4
case .region: return 2 case .region: return 2
} }
} }
var displayName: String { var displayName: String {
+22 -5
View File
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
let scheduleAfterglow: (TimeInterval) -> Void let scheduleAfterglow: (TimeInterval) -> Void
} }
/// Outcome of an accepted announce, surfaced so the service can run
/// follow-up work (e.g. courier handover) that keys off the announce.
struct BLEAnnounceHandlingResult {
let peerID: PeerID
let announcement: AnnouncementPacket
let isDirectAnnounce: Bool
let isVerified: Bool
}
/// Orchestrates inbound announce packets: preflight validation, signature /// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification, /// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response. /// gossip tracking, and the reciprocal announce response.
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
self.environment = environment self.environment = environment
} }
func handle(_ packet: BitchatPacket, from peerID: PeerID) { @discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
let env = environment let env = environment
let now = env.now() let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate( let preflight = BLEAnnouncePreflightPolicy.evaluate(
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
announcement = acceptance.announcement announcement = acceptance.announcement
case .reject(.malformed): case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session) SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return return nil
case .reject(.senderMismatch(let derivedFromKey)): case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return return nil
case .reject(.selfAnnounce): case .reject(.selfAnnounce):
return return nil
case .reject(.stale(let ageSeconds)): case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return return nil
} }
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
@@ -210,5 +220,12 @@ final class BLEAnnounceHandler {
let delay = Double.random(in: 0.3...0.6) let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay) env.scheduleAfterglow(delay)
} }
return BLEAnnounceHandlingResult(
peerID: peerID,
announcement: announcement,
isDirectAnnounce: isDirectAnnounce,
isVerified: verifiedAnnounce
)
} }
} }
+64 -6
View File
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
packetType: UInt8, packetType: UInt8,
messageID: String messageID: String
) -> BLEFanoutSelection { ) -> BLEFanoutSelection {
let rawAllowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
)
if let directedPeerHint,
let directedSelection = directLinks(
to: directedPeerHint,
links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return directedSelection
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
let allowed = collapseDuplicateLinksPerPeer( let allowed = collapseDuplicateLinksPerPeer(
allowedLinks( rawAllowed,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings centralPeerBindings: centralPeerBindings
) )
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs) return (allowedPeripheralIDs, allowedCentralIDs)
} }
private static func directLinks(
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
return nil
}
return BLEFanoutSelection(
peripheralIDs: Set(directLinks.peripheralIDs),
centralIDs: Set(directLinks.centralIDs)
)
}
private static func hasBoundLink(
to peerID: PeerID,
peripheralIDs: [String],
centralIDs: [String],
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> Bool {
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
}
// Dual-role pairs hold two live links (we-as-central writing to their // Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same // peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler // packet down both doubles airtime for nothing the receiver's assembler
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
return false return false
} }
} }
+186 -9
View File
@@ -46,6 +46,20 @@ final class BLEService: NSObject {
// 4. Efficient Message Deduplication // 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator() private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline
// third parties, and the trust gate for accepting deposits. Injectable
// for tests; main-actor policy because favorites live on the main actor.
var courierStore: CourierStore = .shared
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
}
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances.
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker() private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
@@ -888,6 +902,10 @@ final class BLEService: NSObject {
} else { } else {
packetToSend = packet packetToSend = packet
} }
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
@@ -1047,6 +1065,9 @@ final class BLEService: NSObject {
// Directed send helper (unicast to a specific peerID) without altering packet contents // Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return } guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
} }
@@ -1305,7 +1326,7 @@ extension BLEService: GossipSyncManager.Delegate {
extension BLEService: CBCentralManagerDelegate { extension BLEService: CBCentralManagerDelegate {
#if os(iOS) #if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
@@ -1987,7 +2008,7 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
#if os(iOS) #if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
@@ -2468,7 +2489,147 @@ extension BLEService {
ttl: messageTTL ttl: messageTTL
) )
} }
// MARK: Courier Store-and-Forward
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
/// the envelope to the given couriers for physical delivery. Returns false
/// when no courier is connected, the payload cannot be built, or sealing
/// fails; link writes are queued asynchronously after the envelope is ready.
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
let connected = couriers.filter { isPeerConnected($0) }
guard !connected.isEmpty,
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
return false
}
let payload: Data
do {
let now = Date()
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
} catch {
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
return false
}
messageQueue.async { [weak self] in
guard let self else { return }
for courier in connected {
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
}
}
return true
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
}
/// Handles both courier roles for an incoming envelope addressed to us:
/// recipient (the rotating tag matches our static key open and deliver)
/// or courier (a trusted peer is depositing mail for someone else store).
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
// Directed packets only; envelopes addressed elsewhere ride the
// generic relay path untouched.
guard packet.recipientID == myPeerIDData else { return }
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
let myKey = noiseService.getStaticPublicKeyData()
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
openCourierEnvelope(envelope)
} else {
acceptCourierDeposit(envelope, from: peerID)
}
}
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
do {
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
guard let typeRaw = typedPayload.first,
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
return
}
// Couriered mail arrives while the sender is absent, so the UI's
// block check can't resolve their fingerprint from a live session.
// Gate here, where the full static key is in hand.
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
return
}
// A present sender resolves to their live mesh thread via the
// derived short ID. An absent sender the usual courier case
// uses the full noise-key ID so the message lands on the stable
// favorite conversation instead of an unresolvable short-ID
// thread labeled "Unknown".
let shortID = PeerID(publicKey: senderStaticKey)
let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
let payload = Data(typedPayload.dropFirst())
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))", category: .session)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID,
type: payloadType,
payload: payload,
timestamp: Date()
))
}
} catch {
// Tag collision or stale key: not addressed to us after all.
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
}
}
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return
}
let store = courierStore
let policy = courierDepositPolicy
Task { @MainActor in
guard policy(depositorKey) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
return
}
if store.deposit(envelope, from: depositorKey) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))", category: .session)
}
}
}
/// Hand over any carried envelopes addressed to a peer we just heard from.
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
}
}
// MARK: Link capability snapshots (thread-safe via bleQueue) // MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T { private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -2601,8 +2762,11 @@ extension BLEService {
// MARK: Private Message Handling // MARK: Private Message Handling
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) { private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
// Sessions and wire recipient IDs are keyed by the short 16-hex form;
// callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
let recipientID = recipientID.toShort()
SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session) SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)
// Check if we have an established Noise session // Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: recipientID) { if noiseService.hasEstablishedSession(with: recipientID) {
// Encrypt and send // Encrypt and send
@@ -2700,7 +2864,7 @@ extension BLEService {
// Notify delegate of failure // Notify delegate of failure
notifyUI { [weak self] in notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed"))) self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
} }
} }
} }
@@ -2953,13 +3117,15 @@ extension BLEService {
case .fileTransfer: case .fileTransfer:
handleFileTransfer(packet, from: senderID) handleFileTransfer(packet, from: senderID)
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
case .none: case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
} }
if forwardAlongRouteIfNeeded(packet) { if forwardAlongRouteIfNeeded(packet) {
@@ -3016,7 +3182,18 @@ extension BLEService {
} }
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
announceHandler.handle(packet, from: peerID) let result = announceHandler.handle(packet, from: peerID)
// Courier handover: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them.
// Direct announces only: envelopes are removed from the store
// optimistically, so handover must ride an established link rather
// than a speculative multi-hop send toward a relayed announce.
guard !courierStore.isEmpty,
let result,
result.isVerified,
result.isDirectAnnounce else { return }
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
} }
/// Builds the announce handler environment. All queue hops stay here so /// Builds the announce handler environment. All queue hops stay here so
+40 -25
View File
@@ -51,8 +51,9 @@ protocol CommandContextProvider: AnyObject {
func addPublicSystemMessage(_ content: String) func addPublicSystemMessage(_ content: String)
// MARK: - Favorites // MARK: - Favorites
/// Toggles the favorite via the unified peer flow, which persists by the
/// real noise key and notifies the peer over mesh or Nostr.
func toggleFavorite(peerID: PeerID) func toggleFavorite(peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
} }
/// Processes chat commands in a focused, efficient way /// Processes chat commands in a focused, efficient way
@@ -105,11 +106,28 @@ final class CommandProcessor {
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
case "/help":
return .success(message: Self.helpText)
default: default:
return .error(message: "unknown command: \(cmd)") return .error(message: "unknown command: \(cmd) — type /help for commands")
} }
} }
/// Local-only command reference, printed as a system message. The
/// suggestion panel hides once arguments are typed, and typos used to
/// dead-end in a bare "unknown command" this is the way out.
static let helpText = """
commands:
/msg @name [message] start a private chat
/who list who's here
/clear clear this chat
/hug @name send a hug
/slap @name slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name favorites (mesh only)
/help this list
"""
// MARK: - Command Handlers // MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult { private func handleMessage(_ args: String) -> CommandResult {
@@ -318,34 +336,31 @@ final class CommandProcessor {
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>") return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
} }
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname), guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)") return .error(message: "can't find peer: \(nickname)")
} }
if add { // Resolve current state by the peer's real noise key. The resolved
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) // peerID is either the short 16-hex mesh ID or the full 64-hex
FavoritesPersistenceService.shared.addFavorite( // noise-key ID (offline favorite row) never the noise key itself.
peerNoisePublicKey: noisePublicKey, let isCurrentlyFavorite: Bool
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, if let noiseKey = peerID.noiseKey {
peerNickname: nickname isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
return .success(message: "added \(nickname) to favorites")
} else { } else {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
return .success(message: "removed \(nickname) from favorites")
} }
guard add != isCurrentlyFavorite else {
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
}
// toggleFavorite persists by the real noise key and notifies the peer.
contextProvider?.toggleFavorite(peerID: peerID)
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
} }
} }
+219
View File
@@ -0,0 +1,219 @@
//
// CourierStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Combine
import Foundation
/// Holds courier envelopes this device is carrying for offline third parties.
///
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
/// never learns sender, recipient, or content. Strict quotas keep the device
/// from becoming a public mailbag: bounded count, bounded per-depositor
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
/// retention policy. Carried mail is included in the panic wipe.
final class CourierStore {
struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data
let expiry: UInt64
let ciphertext: Data
let depositorNoiseKey: Data
let storedAt: Date
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
}
}
enum Limits {
static let maxEnvelopes = 20
static let maxPerDepositor = 5
/// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60
}
static let shared = CourierStore()
/// Number of envelopes currently carried, published on the main thread
/// so the UI can show a "carrying mail" indicator.
@Published private(set) var carriedCount: Int = 0
/// Fast path so hot code (announce handling) can skip tag computation.
var isEmpty: Bool {
queue.sync { envelopes.isEmpty }
}
private var envelopes: [StoredEnvelope] = []
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or
/// validity checks reject it. Trust policy (mutual favorite) is the
/// caller's responsibility; this store only enforces resource bounds.
@discardableResult
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty,
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
!envelope.isExpired(at: date) else {
return false
}
// Reject expiries beyond the policy lifetime so depositors can't pin
// storage longer than the outbox would retain the message itself.
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
return false
}
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently.
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
return true
}
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
return false
}
if envelopes.count >= Limits.maxEnvelopes {
// Oldest-first eviction, matching outbox overflow behavior.
let evicted = envelopes.removeFirst()
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
}
envelopes.append(StoredEnvelope(
recipientTag: envelope.recipientTag,
expiry: envelope.expiry,
ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey,
storedAt: date
))
persistLocked()
return true
}
}
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
}
}
// MARK: - Maintenance
func pruneExpired() {
let date = now()
queue.sync {
pruneExpiredLocked(at: date)
persistLocked()
}
}
/// Panic wipe: drop all carried mail from memory and disk.
func wipe() {
queue.sync {
envelopes.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
publishCountLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
let before = envelopes.count
envelopes.removeAll { $0.envelope.isExpired(at: date) }
if envelopes.count != before {
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
}
}
private func publishCountLocked() {
let count = envelopes.count
DispatchQueue.main.async { [weak self] in
self?.carriedCount = count
}
}
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
return
}
envelopes = stored
pruneExpiredLocked(at: now())
publishCountLocked()
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("courier", isDirectory: true)
.appendingPathComponent("envelopes.json")
}
}
@@ -141,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil peerNostrPublicKey: String? = nil
) { ) {
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" // Callers that can't resolve the live nickname pass the "Unknown"
// placeholder (e.g. a notification arriving before the announce);
// never let it clobber a real stored nickname.
let incoming = peerNickname.flatMap { name in
(name.isEmpty || name == "Unknown") ? nil : name
}
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session) SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
+77 -2
View File
@@ -2,11 +2,37 @@ import BitLogger
import BitFoundation import BitFoundation
import Foundation import Foundation
/// Trust and identity lookups the router needs to pick couriers. Backed by
/// the favorites store in production; injectable for tests.
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
/// Whether a peer (by Noise static key) may carry our mail.
var isTrustedCourier: (Data) -> Bool
@MainActor
static func favoritesBacked() -> CourierDirectory {
CourierDirectory(
noiseKey: { peerID in
// Offline favorites are addressed by the full 64-hex
// noise-key ID, which carries the key itself; the favorites
// lookup only resolves short 16-hex IDs.
peerID.noiseKey
?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
},
isTrustedCourier: { noiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
}
)
}
}
/// Routes messages using available transports (Mesh, Nostr, etc.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
private let transports: [Transport] private let transports: [Transport]
private let now: () -> Date private let now: () -> Date
private let courierDirectory: CourierDirectory
/// Invoked whenever a retained private message is dropped without a /// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -14,6 +40,12 @@ final class MessageRouter {
/// stale "sending/sent" state forever. /// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at
/// least one courier (a connected mutual favorite who will physically
/// carry the sealed envelope). Delivery stays best-effort: the outbox
/// retains the message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction // Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage { private struct QueuedMessage {
let content: String let content: String
@@ -31,10 +63,17 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never // Bound resends of messages sent on a weak reachability signal that never
// get a delivery ack (e.g. peer on an old client that doesn't ack). // get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8 private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3
init(transports: [Transport], now: @escaping () -> Date = Date.init) { init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil
) {
self.transports = transports self.transports = transports
self.now = now self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -51,7 +90,7 @@ final class MessageRouter {
} }
// Handle key updates // Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data, if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool { note.userInfo?["isKeyUpdate"] is Bool {
let peerID = PeerID(publicKey: newKey) let peerID = PeerID(publicKey: newKey)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
@@ -89,11 +128,47 @@ final class MessageRouter {
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID) enqueue(message, for: peerID)
// "Reachable" without prompt delivery means the send only joined
// a queue (Nostr with relays down): also hand a sealed copy to
// any connected couriers rather than waiting for internet that
// may never come. Double delivery is harmless receivers dedup
// by message ID, and delivered/read acks never downgrade.
if !transport.canDeliverPromptly(to: peerID) {
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
}
} else { } else {
var unsent = message var unsent = message
unsent.sendAttempts = 0 unsent.sendAttempts = 0
enqueue(unsent, for: peerID) enqueue(unsent, for: peerID)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
}
}
/// Last resort when no transport can deliver promptly the peer is
/// unreachable, or only reachable through a send queue waiting on
/// internet: seal the message to their known static key and hand it to
/// connected mutual favorites who may physically encounter them. The
/// queued copy above stays retained, so direct delivery still wins if
/// the peer reappears first (receivers dedup by message ID).
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
for transport in transports {
let couriers = transport.currentPeerSnapshots()
.filter { snapshot in
guard snapshot.isConnected,
let key = snapshot.noisePublicKey,
key != recipientKey else { return false }
return courierDirectory.isTrustedCourier(key)
}
.prefix(Self.maxCouriersPerMessage)
.map(\.peerID)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
onMessageCarried?(messageID, peerID)
return
}
} }
} }
+45 -2
View File
@@ -369,6 +369,49 @@ final class NoiseEncryptionService {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
} }
// MARK: - Courier Envelopes (one-way Noise X)
/// Domain separation for courier envelopes so X-pattern transcripts can
/// never be confused with interactive XX handshakes.
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
/// Encrypt a payload to a peer's known static key without an interactive
/// handshake (Noise X pattern). Used for store-and-forward envelopes
/// carried by couriers while the recipient is offline.
/// - Warning: One-way messages have no forward secrecy: a later compromise
/// of the recipient's static key exposes envelopes captured in transit.
/// Use established sessions whenever the peer is reachable.
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.courierPrologue
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt a courier envelope addressed to our static key. Returns the
/// payload and the sender's authenticated static public key (the `ss`
/// DH in the X pattern binds the sender's identity to the ciphertext).
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
prologue: Self.courierPrologue
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
/// Clear persistent identity (for panic mode) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
@@ -428,7 +471,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data() var out = Data()
// context // context
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data() let context = Data("bitchat-announce-v1".utf8)
out.append(UInt8(min(context.count, 255))) out.append(UInt8(min(context.count, 255)))
out.append(context.prefix(255)) out.append(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -444,7 +487,7 @@ final class NoiseEncryptionService {
out.append(ed32) out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes // nickname length + bytes
let nickData = nickname.data(using: .utf8) ?? Data() let nickData = Data(nickname.utf8)
out.append(UInt8(min(nickData.count, 255))) out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255)) out.append(nickData.prefix(255))
// timestamp // timestamp
+32 -9
View File
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
let registerPendingGiftWrap: @MainActor (String) -> Void let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
/// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would
/// still queue.
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
@MainActor
static func live(idBridge: NostrIdentityBridge) -> Dependencies { static func live(idBridge: NostrIdentityBridge) -> Dependencies {
Dependencies( Dependencies(
notificationCenter: .default, notificationCenter: .default,
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
sendEvent: { NostrRelayManager.shared.sendEvent($0) }, sendEvent: { NostrRelayManager.shared.sendEvent($0) },
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
} },
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
) )
} }
} }
@@ -49,6 +56,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
// Reachability Cache (thread-safe) // Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = [] private var reachablePeers: Set<PeerID> = []
// Mirror of the relay manager's connection state, cached here because
// canDeliverPromptly is called synchronously off the main actor.
private var relaysConnected = false
private var relayConnectivityCancellable: AnyCancellable?
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor @MainActor
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.reachablePeers = Set(reachable) self.reachablePeers = Set(reachable)
} }
relayConnectivityCancellable = self.dependencies.relayConnectivity()
.sink { [weak self] connected in
guard let self else { return }
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
}
} }
deinit { deinit {
@@ -125,19 +142,25 @@ final class NostrTransport: Transport, @unchecked Sendable {
func isPeerConnected(_ peerID: PeerID) -> Bool { false } func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func isPeerReachable(_ peerID: PeerID) -> Bool { func isPeerReachable(_ peerID: PeerID) -> Bool {
queue.sync { // Callers address peers by either the short 16-hex ID or the full
// Check if exact match // 64-hex noise key (offline favorites), so compare in short form.
let short = peerID.toShort()
return queue.sync {
if reachablePeers.contains(peerID) { return true } if reachablePeers.contains(peerID) { return true }
// Check for short ID match return reachablePeers.contains(where: { $0.toShort() == short })
if peerID.isShort {
return reachablePeers.contains(where: { $0.toShort() == peerID })
}
return false
} }
} }
func canDeliverPromptly(to peerID: PeerID) -> Bool {
// A known npub makes a peer "reachable", but with no relay
// connection a send only joins the local queue. Answering honestly
// here lets the router hand a sealed copy to a courier in parallel
// instead of waiting for internet that may never come.
isPeerReachable(peerID) && queue.sync { relaysConnected }
}
func peerNickname(peerID: PeerID) -> String? { nil } func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] } func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
+1 -1
View File
@@ -111,7 +111,7 @@ final class NotificationService {
func requestAuthorization() { func requestAuthorization() {
guard !isRunningTests else { return } guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted { if granted {
// Permission granted // Permission granted
} else { } else {
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered: case .read, .delivered:
externalReceipts.insert(message.id) externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent: case .failed, .partiallyDelivered, .sending, .sent, .carried:
break break
} }
} }
+16
View File
@@ -54,6 +54,11 @@ protocol Transport: AnyObject {
// Connectivity and peers // Connectivity and peers
func isPeerConnected(_ peerID: PeerID) -> Bool func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool func isPeerReachable(_ peerID: PeerID) -> Bool
/// Whether a send to this peer is likely to leave the device promptly.
/// Distinct from reachability: Nostr claims any favorite with a known
/// npub as reachable even with no relay connection, where a send only
/// joins a queue waiting for internet that may never come.
func canDeliverPromptly(to peerID: PeerID) -> Bool
func peerNickname(peerID: PeerID) -> String? func peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [PeerID: String] func getPeerNicknames() -> [PeerID: String]
@@ -95,6 +100,12 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String) func cancelTransfer(_ transferId: String)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// QR verification (optional for transports) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -105,6 +116,10 @@ protocol Transport: AnyObject {
} }
extension Transport { extension Transport {
// Reachability implies prompt delivery for transports that hand packets
// straight to the radio; queue-backed transports override this.
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
// Noise identity hooks default to inert for transports that do not carry // Noise identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport). // Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
@@ -120,6 +135,7 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
+38 -17
View File
@@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var enrichedPeers: [BitchatPeer] = [] var enrichedPeers: [BitchatPeer] = []
var connected: Set<PeerID> = [] var connected: Set<PeerID> = []
var addedPeerIDs: Set<PeerID> = [] var addedPeerIDs: Set<PeerID> = []
var meshNoiseKeys: Set<Data> = []
// Phase 1: Add all mesh peers (connected and reachable) // Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers { for peerInfo in meshPeers {
let peerID = peerInfo.peerID let peerID = peerInfo.peerID
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh( let peer = buildPeerFromMesh(
peerInfo: peerInfo, peerInfo: peerInfo,
favorites: favorites, favorites: favorites,
meshAttached: hasAnyConnected meshAttached: hasAnyConnected
) )
enrichedPeers.append(peer) enrichedPeers.append(peer)
if peer.isConnected { connected.insert(peerID) } if peer.isConnected { connected.insert(peerID) }
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
if let publicKey = peerInfo.noisePublicKey { if let publicKey = peerInfo.noisePublicKey {
meshNoiseKeys.insert(publicKey)
fingerprintCache[peerID] = publicKey.sha256Fingerprint() fingerprintCache[peerID] = publicKey.sha256Fingerprint()
} }
} }
// Phase 2: Add offline favorites that we actively favorite // Phase 2: Add offline favorites that we actively favorite.
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
// the full 32-byte noise key, so dedup must compare noise keys a
// PeerID comparison between the two forms can never match.
for (favoriteKey, favorite) in favorites where favorite.isFavorite { for (favoriteKey, favorite) in favorites where favorite.isFavorite {
if meshNoiseKeys.contains(favoriteKey) { continue }
let peerID = PeerID(hexData: favoriteKey) let peerID = PeerID(hexData: favoriteKey)
// Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue } if addedPeerIDs.contains(peerID) { continue }
// Skip if connected under different ID but same nickname
let isConnectedByNickname = enrichedPeers.contains {
$0.nickname == favorite.peerNickname && $0.isConnected
}
if isConnectedByNickname { continue }
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
enrichedPeers.append(peer) enrichedPeers.append(peer)
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
} }
@@ -257,7 +256,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return false return false
} }
/// Block or unblock a mesh peer by its stable Noise identity.
///
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
/// (cache / mesh session / known-peer Noise key). This works even when the
/// peer is offline including offline favorites so the exact tapped peer
/// is (un)blocked unambiguously instead of being re-resolved by a
/// display-name string that two peers could share.
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
@discardableResult
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
guard let fingerprint = getFingerprint(for: peerID) else {
SecureLogger.warning(
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
category: .session
)
return nil
}
identityManager.setBlocked(fingerprint, isBlocked: blocked)
updatePeers()
return fingerprint
}
/// Toggle favorite status /// Toggle favorite status
func toggleFavorite(_ peerID: PeerID) { func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
+3
View File
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5 case .fragment: return 5
case .requestSync: return 6 case .requestSync: return 6
case .fileTransfer: return 7 case .fileTransfer: return 7
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
} }
} }
@@ -27,4 +27,3 @@ struct PeerDisplayNameResolver {
return result return result
} }
} }
@@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1 case .failed: return 1
case .sending: return 2 case .sending: return 2
case .sent: return 3 case .sent: return 3
case .partiallyDelivered: return 4 case .carried: return 4
case .delivered: return 5 case .partiallyDelivered: return 5
case .read: return 6 case .delivered: return 6
case .read: return 7
} }
} }
} }
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
} }
} catch { } catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session) SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
} }
} }
} }
+3 -58
View File
@@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Favorites & notifications (shared with the other contexts) // MARK: Favorites (shared with the other contexts)
/// The persisted favorite relationship for the peer's Noise static key, if any. /// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// Adds (or updates) a favorite in the favorites store.
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
/// Posts a generic local user notification.
func postLocalNotification(title: String, body: String, identifier: String)
} }
extension ChatViewModel: ChatNostrContext { extension ChatViewModel: ChatNostrContext {
@@ -71,7 +67,7 @@ final class ChatNostrCoordinator {
key: Data? key: Data?
) { ) {
guard let context else { return } guard let context else { return }
if let _ = key { if key != nil {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
} }
@@ -84,7 +80,7 @@ final class ChatNostrCoordinator {
} }
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID { if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key { if key != nil {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
} }
@@ -98,57 +94,6 @@ final class ChatNostrCoordinator {
} }
} }
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
guard let context else { return }
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
let isFavorite = content.contains("FAVORITE:TRUE")
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
if isFavorite {
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: nostrPubkey,
nickname: senderNickname
)
}
var extractedNostrPubkey: String?
if let range = content.range(of: "NPUB:") {
let suffix = content[range.upperBound...]
let parts = suffix.components(separatedBy: "|")
if let key = parts.first {
extractedNostrPubkey = String(key)
}
} else if content.contains(":") {
let parts = content.components(separatedBy: ":")
if parts.count >= 3 {
extractedNostrPubkey = String(parts[2])
}
}
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
if isFavorite && extractedNostrPubkey != nil {
SecureLogger.info(
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
category: .session
)
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: extractedNostrPubkey,
nickname: senderNickname
)
}
context.postLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
)
}
@MainActor @MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
guard let context else { return } guard let context else { return }
@@ -92,7 +92,6 @@ protocol ChatPrivateConversationContext: AnyObject {
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
// MARK: System messages // MARK: System messages
func addSystemMessage(_ content: String) func addSystemMessage(_ content: String)
@@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject {
// MARK: Favorites & notifications // MARK: Favorites & notifications
/// The persisted favorite relationship for the peer's Noise static key, if any. /// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// The persisted favorite relationship resolved from a short 16-hex mesh
/// peer ID (matched against the IDs derived from stored noise keys).
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
/// Persists that the peer favorited/unfavorited us (favorites store write). /// Persists that the peer favorited/unfavorited us (favorites store write).
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
/// Posts the incoming-private-message local notification. /// Posts the incoming-private-message local notification.
@@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
} }
// `favoriteRelationship(forPeerID:)` is shared with
// `ChatPeerIdentityContext`; its witness lives in
// `ChatPeerIdentityCoordinator.swift`.
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
FavoritesPersistenceService.shared.updatePeerFavoritedUs( FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: noiseKey, peerNoisePublicKey: noiseKey,
@@ -245,10 +251,15 @@ final class ChatPrivateConversationCoordinator {
return return
} }
guard let noiseKey = Data(hexString: peerID.id) else { return } // Resolve the favorite behind this conversation. It may be keyed by
// the full 64-hex noise-key ID (offline favorite row) or the short
// 16-hex mesh ID the raw hex bytes of a short ID are a routing ID,
// never a noise key, so they must not be used as a favorites key.
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
let isConnected = context.isPeerConnected(peerID) let isConnected = context.isPeerConnected(peerID)
let isReachable = context.isPeerReachable(peerID) let isReachable = context.isPeerReachable(peerID)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) }
?? context.favoriteRelationship(forPeerID: peerID)
let isMutualFavorite = favoriteStatus?.isMutual ?? false let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
@@ -405,9 +416,32 @@ final class ChatPrivateConversationCoordinator {
return return
} }
// Prefer the favorite's stored nickname when the sender resolved to a
// known noise key; the Nostr display name is a geohash-scoped
// fallback (e.g. "anon#678e") that would mislabel favorite-transport
// DMs. Geohash conversations (nostr_ keys) keep the geo name.
let senderName: String = {
if let noiseKey = convKey.noiseKey,
let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname,
!favoriteNickname.isEmpty {
return favoriteNickname
}
return context.displayNameForNostrPubkey(senderPubkey)
}()
// Favorite notifications ride the PM channel over Nostr too; intercept
// them so they update the relationship instead of rendering as text.
if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotification(
pm.content,
from: convKey,
senderNickname: senderName
)
return
}
if context.privateChatsContainMessage(withID: messageId) { return } if context.privateChatsContainMessage(withID: messageId) { return }
let senderName = context.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage( let message = BitchatMessage(
id: messageId, id: messageId,
sender: senderName, sender: senderName,
@@ -486,93 +520,6 @@ final class ChatPrivateConversationCoordinator {
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
} }
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
let messageContent = pm.content
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(
messageContent,
from: PeerID(hexData: key),
senderNickname: senderNickname
)
}
return
}
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
return
}
let wasReadBefore = context.sentReadReceipts.contains(messageId)
var isViewingThisChat = false
if context.selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = context.selectedPrivateChatPeer,
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerNoiseKey == key {
isViewingThisChat = true
}
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: context.nickname,
senderPeerID: targetPeerID,
deliveryStatus: .delivered(to: context.nickname, at: Date())
)
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
context.sendDeliveryAckViaNostrEmbedded(
message,
wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey,
key: actualSenderNoiseKey
)
if wasReadBefore {
// No-op.
} else if isViewingThisChat {
handleViewingThisChat(
message,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
senderPubkey: senderPubkey
)
} else {
markAsUnreadIfNeeded(
shouldMarkAsUnread: shouldMarkAsUnread,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
isRecentMessage: isRecentMessage,
senderNickname: senderNickname,
messageContent: messageContent
)
}
context.notifyUIChanged()
}
func handlePrivateMessage(_ message: BitchatMessage) { func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
@@ -583,7 +530,7 @@ final class ChatPrivateConversationCoordinator {
} }
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender)
return return
} }
@@ -706,7 +653,10 @@ final class ChatPrivateConversationCoordinator {
} }
} }
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { /// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either
/// transport. `peerID` must resolve to a noise key a full 64-hex ID or
/// one the unified peer list knows; otherwise the notification is dropped.
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
let isFavorite = content.hasPrefix("[FAVORITED]") let isFavorite = content.hasPrefix("[FAVORITED]")
let parts = content.split(separator: ":") let parts = content.split(separator: ":")
+61 -3
View File
@@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
) )
} }
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
// command, these resolve and persist the block by the peer's stable
// fingerprint (derived from `peerID`), so the exact tapped peer is
// (un)blocked unambiguous across nickname collisions and functional for
// offline peers that can no longer be resolved through the mesh service.
@MainActor
func blockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
}
@MainActor
func unblockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
}
@MainActor
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
),
locale: .current,
displayName
)
)
return
}
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
comment: "System message shown when a mesh peer is blocked or unblocked"
),
locale: .current,
displayName
)
)
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
} }
@@ -1147,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk)
CourierStore.shared.wipe()
// Identity manager has cleared persisted identity data above // Identity manager has cleared persisted identity data above
// Clear autocomplete state // Clear autocomplete state
@@ -1435,7 +1480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Processes IRC-style commands starting with '/'. /// Processes IRC-style commands starting with '/'.
/// - Parameter command: The full command string including the leading slash /// - Parameter command: The full command string including the leading slash
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help /// - Note: Supports commands like /msg, /who, /slap, /clear, /help
@MainActor @MainActor
func handleCommand(_ command: String) { func handleCommand(_ command: String) {
let result = commandProcessor.process(command) let result = commandProcessor.process(command)
@@ -1443,16 +1488,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
switch result { switch result {
case .success(let message): case .success(let message):
if let msg = message { if let msg = message {
addSystemMessage(msg) addCommandOutput(msg)
} }
case .error(let message): case .error(let message):
addSystemMessage(message) addCommandOutput(message)
case .handled: case .handled:
// Command was handled, no message needed // Command was handled, no message needed
break break
} }
} }
/// Command output belongs in the conversation where the user typed the
/// command; the public timeline is invisible while a DM is open. The DM
/// selection is read *after* processing so commands that switch chats
/// (`/msg`) print into the conversation they just opened.
@MainActor
private func addCommandOutput(_ content: String) {
if let peerID = selectedPrivateChatPeer {
addLocalPrivateSystemMessage(content, to: peerID)
} else {
addSystemMessage(content)
}
}
// MARK: - Message Reception // MARK: - Message Reception
@MainActor @MainActor
@@ -100,11 +100,28 @@ private extension ChatViewModelBootstrapper {
category: .session category: .session
) )
viewModel.conversations.setDeliveryStatus( viewModel.conversations.setDeliveryStatus(
.failed(reason: "Not delivered"), .failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
forMessageID: messageID forMessageID: messageID
) )
} }
} }
// A message with no reachable transport that was handed to a courier
// shows a distinct "carried" state instead of sitting in "sending"
// forever. Never downgrade a confirmed receipt: the courier copy can
// race direct delivery when the peer reappears.
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
guard let viewModel else { return }
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
case .delivered, .read:
break
default:
SecureLogger.debug(
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
category: .session
)
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
}
}
viewModel.commandProcessor.contextProvider = viewModel viewModel.commandProcessor.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel) viewModel.participantTracker.configure(context: viewModel)
@@ -109,11 +109,6 @@ extension ChatViewModel {
) )
} }
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
}
@MainActor @MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite) nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
@@ -121,25 +121,6 @@ extension ChatViewModel {
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID) mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
} }
@MainActor
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
privateConversationCoordinator.handlePrivateMessage(
payload,
actualSenderNoiseKey: actualSenderNoiseKey,
senderNickname: senderNickname,
targetPeerID: targetPeerID,
messageTimestamp: messageTimestamp,
senderPubkey: senderPubkey
)
}
@MainActor @MainActor
func handlePrivateMessage(_ message: BitchatMessage) { func handlePrivateMessage(_ message: BitchatMessage) {
privateConversationCoordinator.handlePrivateMessage(message) privateConversationCoordinator.handlePrivateMessage(message)
@@ -190,8 +171,8 @@ extension ChatViewModel {
} }
@MainActor @MainActor
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
privateConversationCoordinator.handleFavoriteNotificationFromMesh( privateConversationCoordinator.handleFavoriteNotification(
content, content,
from: peerID, from: peerID,
senderNickname: senderNickname senderNickname: senderNickname
@@ -442,8 +442,7 @@ final class NostrInboundPipeline {
} }
/// Resolves the Noise static key behind a Nostr pubkey via the favorites /// Resolves the Noise static key behind a Nostr pubkey via the favorites
/// store. Lives here because the inbound DM path needs it per message; /// store. Lives here because the inbound DM path needs it per message.
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
@MainActor @MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? { func findNoiseKey(for nostrPubkey: String) -> Data? {
guard let context else { return nil } guard let context else { return nil }
+44 -8
View File
@@ -55,6 +55,26 @@ struct AppInfoView: View {
) )
} }
enum Legend {
static let title: LocalizedStringKey = "app_info.legend.title"
/// Every glyph the peer lists and headers use, in one place
/// nothing else in the app defines them.
static let items: [(icon: String, text: LocalizedStringKey)] = [
("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"),
("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"),
("globe", "app_info.legend.nostr"),
("person", "app_info.legend.offline"),
("mappin.and.ellipse", "app_info.legend.location_nearby"),
("face.dashed", "app_info.legend.teleported"),
("lock.fill", "app_info.legend.encrypted"),
("lock.slash", "app_info.legend.encryption_failed"),
("checkmark.seal.fill", "app_info.legend.verified"),
("star.fill", "app_info.legend.favorite"),
("envelope.fill", "app_info.legend.unread"),
("nosign", "app_info.legend.blocked")
]
}
enum Privacy { enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title" static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo( static let noTracking = AppInfoFeatureInfo(
@@ -118,14 +138,8 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) { SheetCloseButton { dismiss() }
Image(systemName: "xmark") .foregroundColor(textColor)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("app_info.close")
} }
} }
} }
@@ -202,6 +216,28 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Features.mentions) FeatureRow(info: Strings.Features.mentions)
} }
// Symbols legend
VStack(alignment: .leading, spacing: 10) {
SectionHeader(Strings.Legend.title)
ForEach(Strings.Legend.items, id: \.icon) { item in
HStack(alignment: .top, spacing: 12) {
Image(systemName: item.icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.frame(width: 30)
Text(item.text)
.bitchatFont(size: 13)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.accessibilityElement(children: .combine)
}
}
// Privacy // Privacy
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title) SectionHeader(Strings.Privacy.title)
@@ -14,29 +14,46 @@ struct CommandSuggestionsView: View {
@Binding var messageText: String @Binding var messageText: String
/// The command already typed in full, once arguments have begun.
private var typedCommandAlias: String? {
guard messageText.hasPrefix("/"),
let spaceIndex = messageText.firstIndex(of: " ")
else { return nil }
return String(messageText[..<spaceIndex]).lowercased()
}
private var filteredCommands: [CommandInfo] { private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] } guard messageText.hasPrefix("/") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
// While arguments are being typed, keep the matched command's usage
// row visible instead of vanishing at the first space.
if let typed = typedCommandAlias {
return commands.filter { $0.alias == typed && $0.placeholder != nil }
}
return commands.filter { command in
command.alias.starts(with: messageText.lowercased()) command.alias.starts(with: messageText.lowercased())
} }
} }
var body: some View { var body: some View {
// Render nothing when there are no matches: a zero-height view would // Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row // still receive the composer VStack's spacing and push the input row
// off-center. // off-center.
if !filteredCommands.isEmpty { if !filteredCommands.isEmpty {
let isUsageReminder = typedCommandAlias != nil
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in ForEach(filteredCommands) { command in
Button { Button {
// In usage-reminder mode the row is informational; an
// insert here would wipe the arguments being typed.
guard !isUsageReminder else { return }
messageText = command.alias + " " messageText = command.alias + " "
} label: { } label: {
buttonRow(for: command) buttonRow(for: command)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
} }
} }
.themedOverlayPanel() .themedOverlayPanel()
@@ -9,6 +9,47 @@
import SwiftUI import SwiftUI
import BitFoundation import BitFoundation
extension DeliveryStatus {
/// Localized, user-facing description of the status. Used for macOS
/// tooltips, the tap-to-reveal caption under a message, and VoiceOver
/// the glyphs alone are unexplained 10pt icons.
var bitchatDescription: String {
switch self {
case .sending:
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
case .carried:
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
case .delivered(let nickname, _):
return String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
case .read(let nickname, _):
return String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
case .failed(let reason):
return String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
case .partiallyDelivered(let reached, let total):
return String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
}
struct DeliveryStatusView: View { struct DeliveryStatusView: View {
@ThemedPalette private var palette @ThemedPalette private var palette
let status: DeliveryStatus let status: DeliveryStatus
@@ -19,56 +60,34 @@ struct DeliveryStatusView: View {
private var secondaryTextColor: Color { palette.secondary } private var secondaryTextColor: Color { palette.secondary }
private enum Strings {
static func delivered(to nickname: String) -> String {
String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
}
static func read(by nickname: String) -> String {
String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
}
static func failed(_ reason: String) -> String {
String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
}
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
statusGlyph
.help(status.bitchatDescription)
.accessibilityElement(children: .ignore)
.accessibilityLabel(status.bitchatDescription)
}
@ViewBuilder
private var statusGlyph: some View {
switch status { switch status {
case .sending: case .sending:
Image(systemName: "circle") Image(systemName: "circle")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .sent: case .sent:
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .delivered(let nickname, _): case .carried:
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.8))
case .delivered:
HStack(spacing: -2) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
@@ -76,24 +95,22 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
} }
.foregroundColor(textColor.opacity(0.8)) .foregroundColor(textColor.opacity(0.8))
.help(Strings.delivered(to: nickname))
case .read:
case .read(let nickname, _): // Filled variant so read vs delivered is legible without color.
HStack(spacing: -2) { HStack(spacing: 0) {
Image(systemName: "checkmark") Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 10, weight: .bold)) .font(.bitchatSystem(size: 9, weight: .bold))
Image(systemName: "checkmark") Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 10, weight: .bold)) .font(.bitchatSystem(size: 9, weight: .bold))
} }
.foregroundColor(palette.accentBlue) .foregroundColor(palette.accentBlue)
.help(Strings.read(by: nickname))
case .failed:
case .failed(let reason):
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8)) .foregroundColor(Color.red.opacity(0.8))
.help(Strings.failed(reason))
case .partiallyDelivered(let reached, let total): case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) { HStack(spacing: 1) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@@ -102,7 +119,6 @@ struct DeliveryStatusView: View {
.bitchatFont(size: 10) .bitchatFont(size: 10)
} }
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
} }
} }
} }
@@ -111,6 +127,7 @@ struct DeliveryStatusView: View {
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.sending, .sending,
.sent, .sent,
.carried,
.delivered(to: "John Doe", at: Date()), .delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()), .read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"), .failed(reason: "Offline"),
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
private var fgColor: Color { palette.primary } private var fgColor: Color { palette.primary }
private var bgColor: Color { private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
} }
private var border: Color { fgColor.opacity(0.25) } private var border: Color { fgColor.opacity(0.25) }
@@ -0,0 +1,29 @@
//
// SheetCloseButton.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The close "X" every sheet and header shares. One glyph size and weight
/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box
/// so existing header metrics don't move, and a hit target extended to 44pt
/// per platform guidelines. Tint comes from the environment, so callers keep
/// their own foreground color.
struct SheetCloseButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
}
+53 -5
View File
@@ -12,6 +12,7 @@ import BitFoundation
struct TextMessageView: View { struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
@@ -24,6 +25,7 @@ struct TextMessageView: View {
/// the enum makes the change visible to SwiftUI's structural diff. /// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus? private let deliveryStatus: DeliveryStatus?
@State private var expandedMessageIDs: Set<String> = [] @State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
init(message: BitchatMessage) { init(message: BitchatMessage) {
self.message = message self.message = message
@@ -35,19 +37,59 @@ struct TextMessageView: View {
// Precompute heavy token scans once per row // Precompute heavy token scans once per row
let cashuLinks = message.content.extractCashuLinks() let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks() let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) { // Baseline alignment keeps the lock and delivery glyphs on the
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages // Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus { let status = deliveryStatus {
DeliveryStatusView(status: status) Button {
.padding(.leading, 4) showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
} }
} }
@@ -60,7 +102,7 @@ struct TextMessageView: View {
else { expandedMessageIDs.insert(message.id) } else { expandedMessageIDs.insert(message.id) }
} }
.bitchatFont(size: 11, weight: .medium) .bitchatFont(size: 11, weight: .medium)
.foregroundColor(Color.blue) .foregroundColor(palette.accentBlue)
.padding(.top, 4) .padding(.top, 4)
} }
@@ -78,6 +120,12 @@ struct TextMessageView: View {
.padding(.leading, 2) .padding(.leading, 2)
} }
} }
// Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another.
.onChange(of: deliveryStatus) { _ in
showDeliveryDetail = false
}
} }
} }
+67 -8
View File
@@ -6,6 +6,7 @@ import UIKit
struct ContentComposerView: View { struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette @ThemedPalette private var palette
@@ -43,7 +44,6 @@ struct ContentComposerView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
} }
} }
.themedOverlayPanel() .themedOverlayPanel()
@@ -60,10 +60,8 @@ struct ContentComposerView: View {
TextField( TextField(
"", "",
text: $messageText, text: $messageText,
prompt: Text( prompt: Text(placeholderText)
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") .foregroundColor(palette.secondary.opacity(0.6))
)
.foregroundColor(palette.secondary.opacity(0.6))
) )
.textFieldStyle(.plain) .textFieldStyle(.plain)
.bitchatFont(size: 15) .bitchatFont(size: 15)
@@ -110,6 +108,33 @@ struct ContentComposerView: View {
} }
private extension ContentComposerView { private extension ContentComposerView {
/// States where a message will land: the DM partner's name for private
/// chats, the channel (and its public nature) otherwise so a stressed
/// user never has to guess who can read what they're typing.
var placeholderText: String {
if let header = privateConversationModel.selectedHeaderState {
// A geohash-DM display name already carries its own "#geohash/@name"
// form, so it must not get another "@" prefix; a mesh nickname does.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
return String(
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
locale: .current,
target
)
}
switch locationChannelsModel.selectedChannel {
case .mesh:
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
case .location(let channel):
return String(
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
locale: .current,
channel.geohash
)
}
}
var recordingIndicator: some View { var recordingIndicator: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill") Image(systemName: "waveform.circle.fill")
@@ -158,7 +183,19 @@ private extension ContentComposerView {
imagePickerSourceType = .camera imagePickerSourceType = .camera
showImagePicker = true showImagePicker = true
} }
.accessibilityLabel("Tap for library, long press for camera") .accessibilityLabel(
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
)
.accessibilityHint(
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
)
.accessibilityAddTraits(.isButton)
// The long-press camera path is unreachable for VoiceOver users;
// mirror it as a named action.
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
imagePickerSourceType = .camera
showImagePicker = true
}
#else #else
Button(action: { showMacImagePicker = true }) { Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill") Image(systemName: "photo.circle.fill")
@@ -167,7 +204,9 @@ private extension ContentComposerView {
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Choose photo") .accessibilityLabel(
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
)
#endif #endif
} }
@@ -209,7 +248,27 @@ private extension ContentComposerView {
} }
) )
) )
.accessibilityLabel("Hold to record a voice note") .accessibilityLabel(
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
)
.accessibilityValue(
voiceRecordingVM.state.isActive
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
: ""
)
.accessibilityHint(
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
)
.accessibilityAddTraits(.isButton)
// Press-and-hold drag gestures can't be activated by VoiceOver;
// give it a start/stop toggle as the default action.
.accessibilityAction {
if voiceRecordingVM.state.isActive {
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
} else {
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
}
}
} }
func sendButtonView(enabled: Bool) -> some View { func sendButtonView(enabled: Bool) -> some View {
+61 -11
View File
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
let headerPeerIconSize: CGFloat let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat let headerPeerCountFontSize: CGFloat
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text(verbatim: "bitchat/") Text(verbatim: "bitchat/")
@@ -33,6 +36,16 @@ struct ContentHeaderView: View {
.onTapGesture(count: 1) { .onTapGesture(count: 1) {
appChromeModel.presentAppInfo() appChromeModel.presentAppInfo()
} }
// This is the only entry point to App Info, but it reads as
// static text; surface the tap. (The triple-tap panic wipe
// stays undiscoverable on purpose it's destructive.)
.accessibilityAddTraits(.isButton)
.accessibilityHint(
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
)
.accessibilityAction {
appChromeModel.presentAppInfo()
}
HStack(spacing: 0) { HStack(spacing: 0) {
Text(verbatim: "@") Text(verbatim: "@")
@@ -78,6 +91,23 @@ struct ContentHeaderView: View {
}() }()
HStack(spacing: 2) { HStack(spacing: 2) {
if carriedMailCount > 0 {
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.8))
.headerTapTarget()
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
locale: .current,
carriedMailCount
)
)
.help(
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
)
}
if appChromeModel.hasUnreadPrivateMessages { if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
@@ -183,6 +213,13 @@ struct ContentHeaderView: View {
headerOtherPeersCount headerOtherPeersCount
) )
) )
// Connected-vs-nobody is otherwise encoded only in the icon's
// color; say it.
.accessibilityValue(
headerPeersReachable
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
)
} }
.layoutPriority(3) .layoutPriority(3)
.sheet(isPresented: $showVerifySheet) { .sheet(isPresented: $showVerifySheet) {
@@ -190,8 +227,16 @@ struct ContentHeaderView: View {
.environmentObject(verificationModel) .environmentObject(verificationModel)
} }
} }
// Fixed height is load-bearing: children fill the bar with
// .frame(maxHeight: .infinity) tap targets, so an open-ended
// minHeight lets the header expand to swallow the whole screen.
// headerHeight is a @ScaledMetric, so it still grows with Dynamic
// Type.
.frame(height: headerHeight) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel) .environmentObject(locationChannelsModel)
@@ -266,14 +311,25 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1 dynamicTypeSize.isAccessibilitySize ? 2 : 1
} }
/// Whether anyone is actually reachable on the current channel the
/// state the count icon's color encodes visually.
var headerPeersReachable: Bool {
switch locationChannelsModel.selectedChannel {
case .location:
return peerListModel.visibleGeohashPeerCount > 0
case .mesh:
return peerListModel.connectedMeshPeerCount > 0
}
}
func channelPeopleCountAndColor() -> (Int, Color) { func channelPeopleCountAndColor() -> (Int, Color) {
switch locationChannelsModel.selectedChannel { switch locationChannelsModel.selectedChannel {
case .location: case .location:
let count = peerListModel.visibleGeohashPeerCount let count = peerListModel.visibleGeohashPeerCount
return (count, count > 0 ? palette.locationAccent : Color.secondary) return (count, count > 0 ? palette.locationAccent : palette.secondary)
case .mesh: case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
return (peerListModel.reachableMeshPeerCount, color) return (peerListModel.reachableMeshPeerCount, color)
} }
} }
@@ -293,16 +349,10 @@ private struct ContentLocationNotesUnavailableView: View {
Text("content.notes.title") Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold) .bitchatFont(size: 16, weight: .bold)
Spacer() Spacer()
Button(action: { showLocationNotes = false }) { SheetCloseButton { showLocationNotes = false }
Image(systemName: "xmark") .foregroundColor(palette.primary)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
} }
.frame(height: headerHeight) .frame(minHeight: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.themedChromePanel(edge: .top) .themedChromePanel(edge: .top)
Text("content.notes.location_unavailable") Text("content.notes.location_unavailable")
+104 -20
View File
@@ -134,6 +134,7 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -159,24 +160,23 @@ private struct ContentPeopleListView: View {
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
// .help maps to the accessibility *hint* on iOS, so the
// button still needs a spoken name.
.accessibilityLabel(
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
)
.help( .help(
String(localized: "content.help.verification", comment: "Help text for verification button") String(localized: "content.help.verification", comment: "Help text for verification button")
) )
} }
Button(action: { SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss() dismiss()
showSidebar = false showSidebar = false
showVerifySheet = false showVerifySheet = false
privateConversationModel.endConversation() privateConversationModel.endConversation()
} }
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
} }
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
let activeText = String.localizedStringWithFormat( let activeText = String.localizedStringWithFormat(
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
Text(subtitle) Text(subtitle)
.foregroundColor(subtitleColor) .foregroundColor(subtitleColor)
Text(activeText) Text(activeText)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
.bitchatFont(size: 12) .bitchatFont(size: 12)
} else { } else {
Text(activeText) Text(activeText)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -231,6 +231,13 @@ private struct ContentPeopleListView: View {
}, },
onShowFingerprint: { peerID in onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID) appChromeModel.showFingerprint(for: peerID)
},
onToggleBlock: { peer in
if peer.isBlocked {
conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName)
} else {
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
}
} }
) )
} }
@@ -333,6 +340,9 @@ private struct ContentPrivateChatSheetView: View {
Image(systemName: headerState.isFavorite ? "star.fill" : "star") Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) .foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
// Same visual box + 44pt hit target as SheetCloseButton.
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
@@ -346,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
Button(action: { SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation() privateConversationModel.endConversation()
showSidebar = true showSidebar = true
} }
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
} }
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
.frame(height: headerHeight) // minHeight so scaled text at accessibility sizes grows the
// bar instead of clipping inside it.
.frame(minHeight: headerHeight)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 12)
.themedSurface() .modifier(PrivateHeaderChrome())
} }
MessageListView( MessageListView(
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
Divider() Divider()
} }
privacyCaption
#if os(iOS) #if os(iOS)
ContentComposerView( ContentComposerView(
messageText: $messageText, messageText: $messageText,
@@ -421,6 +429,69 @@ private struct ContentPrivateChatSheetView: View {
} }
) )
} }
/// Persistent one-line reminder that this composer feeds a private
/// conversation the DM sheet otherwise renders identically to the
/// public timeline. Claims end-to-end encryption only once the session
/// is actually secured.
private var privacyCaption: some View {
HStack(spacing: 5) {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 9))
// Optical centering: lock.fill's ink is bottom-heavy, so
// geometric centering reads low next to the caption text.
.offset(y: -1)
Text(verbatim: privacyCaptionText)
.bitchatFont(size: 11, weight: .medium)
}
.foregroundColor(Color.orange)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
// The orange text is signature enough; a tinted band here reads as a
// stray strip against the untinted composer chrome below it, so the
// caption sits on the same surface as the rest of the bottom chrome.
.themedSurface()
.accessibilityElement(children: .combine)
}
private var privacyCaptionText: String {
// Geohash DMs are NIP-17 gift-wrapped always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let noiseSecured: Bool = {
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
case .noiseSecured, .noiseVerified: return true
default: return false
}
}()
if isGeoDM || noiseSecured {
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
}
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
}
}
/// Chrome for the private-chat header. Matrix keeps its orange privacy wash
/// over an opaque themed surface. Glass gets the same floating panel as the
/// main header instead: an orange wash over the backdrop gradient reads as a
/// muddy gray-beige band, and the DM signature is already carried by the
/// orange lock, caption, and composer accents.
private struct PrivateHeaderChrome: ViewModifier {
@Environment(\.appTheme) private var theme
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content.themedChromePanel(edge: .top)
} else {
// Orange tint before themedSurface so it layers in front of the
// opaque themed background rather than behind it.
content
.background(Color.orange.opacity(0.06))
.themedSurface()
}
}
} }
private struct ContentPrivateHeaderInfoButton: View { private struct ContentPrivateHeaderInfoButton: View {
@@ -452,17 +523,30 @@ private struct ContentPrivateHeaderInfoButton: View {
.foregroundColor(.purple) .foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline: case .offline:
EmptyView() // Absence of a glyph was the only offline signal; say it.
Text("mesh_peers.state.offline")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
} }
Text(headerState.displayName) Text(headerState.displayName)
.bitchatFont(size: 16, weight: .medium) .bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary) .foregroundColor(palette.primary)
// Middle truncation keeps the identity suffix visible on
// long nicknames instead of wrapping into the fixed-height
// header.
.lineLimit(1)
.truncationMode(.middle)
if let encryptionStatus = headerState.encryptionStatus, if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon { let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
// Optical centering: the lock glyphs' ink is bottom-heavy
// (solid body, thin shackle), so geometric centering reads
// ~1pt low next to the name. The seal badge is symmetric
// and needs no lift.
.offset(y: icon.hasPrefix("lock") ? -1 : 0)
.foregroundColor( .foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? palette.primary ? palette.primary
@@ -489,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View {
.accessibilityHint( .accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
) )
.frame(height: headerHeight) .frame(minHeight: headerHeight)
} }
} }
+5 -8
View File
@@ -54,11 +54,8 @@ struct FingerprintView: View {
Spacer() Spacer()
Button(action: { dismiss() }) { SheetCloseButton { dismiss() }
Image(systemName: "xmark") .foregroundColor(textColor)
.font(.bitchatSystem(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
} }
.padding() .padding()
@@ -83,7 +80,7 @@ struct FingerprintView: View {
Spacer() Spacer()
} }
.padding() .padding()
.background(Color.gray.opacity(0.1)) .background(palette.secondary.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
// Their fingerprint // Their fingerprint
@@ -101,7 +98,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1)) .background(palette.secondary.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button(Strings.copy) { Button(Strings.copy) {
@@ -135,7 +132,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1)) .background(palette.secondary.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button(Strings.copy) { Button(Strings.copy) {
+45 -1
View File
@@ -13,6 +13,13 @@ struct GeohashPeopleList: View {
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels") static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
static let unblock: LocalizedStringKey = "geohash_people.action.unblock" static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block" static let block: LocalizedStringKey = "geohash_people.action.block"
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
} }
var body: some View { var body: some View {
@@ -46,7 +53,12 @@ struct GeohashPeopleList: View {
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse" let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark) let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
let rowColor: Color = person.isMe ? .orange : assignedColor let rowColor: Color = person.isMe ? .orange : assignedColor
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor) Image(systemName: icon)
// Size 10 to match the mesh rows' leading glyphs
// both lists share the sidebar.
.font(.bitchatSystem(size: 10))
.foregroundColor(rowColor)
.help(person.isTeleported ? Strings.teleported : Strings.nearby)
let (base, suffix) = person.displayName.splitSuffix() let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -54,6 +66,8 @@ struct GeohashPeopleList: View {
.bitchatFont(size: 14) .bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular) .fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor) .foregroundColor(rowColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix) Text(suffix)
@@ -105,6 +119,27 @@ struct GeohashPeopleList: View {
} }
} }
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: person))
.accessibilityAddTraits(person.isMe ? [] : .isButton)
.accessibilityHint(person.isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !person.isMe {
Button(person.isBlocked ? Strings.unblockText : Strings.blockText) {
if person.isBlocked {
peerListModel.unblockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
} else {
peerListModel.blockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
}
}
}
}
} }
} }
// Seed and update order outside result builder // Seed and update order outside result builder
@@ -119,4 +154,13 @@ struct GeohashPeopleList: View {
} }
} }
} }
/// One spoken sentence per row: name, presence type, and block state.
private func accessibilityDescription(for person: GeohashPersonRow) -> String {
var parts: [String] = [person.displayName]
if person.isMe { parts.append(Strings.youState) }
parts.append(person.isTeleported ? Strings.teleported : Strings.nearby)
if person.isBlocked { parts.append(Strings.blockedState) }
return parts.joined(separator: ", ")
}
} }
+38 -20
View File
@@ -31,6 +31,9 @@ struct LocationChannelsSheet: View {
static let toggleOff: LocalizedStringKey = "common.toggle.off" static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
static func meshTitle(_ count: Int) -> String { static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
@@ -103,7 +106,7 @@ struct LocationChannelsSheet: View {
} }
Text(Strings.description) Text(Strings.description)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Group { Group {
switch locationChannelsModel.permissionState { switch locationChannelsModel.permissionState {
@@ -122,7 +125,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied) Text(Strings.permissionDenied)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open) Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
} }
private var closeButton: some View { private var closeButton: some View {
Button(action: { isPresented = false }) { SheetCloseButton { isPresented = false }
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
private var channelList: some View { private var channelList: some View {
@@ -210,7 +207,10 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .padding(.leading, 8)
} .accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
) { ) {
locationChannelsModel.markTeleported(for: channel.geohash, false) locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel)) locationChannelsModel.select(ChannelID.location(channel))
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 2) { HStack(spacing: 2) {
Text(verbatim: "#") Text(verbatim: "#")
.bitchatFont(size: 14) .bitchatFont(size: 14)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS) #if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
.bitchatFont(size: 14) .bitchatFont(size: 14)
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12)) .background(palette.secondary.opacity(0.12))
.cornerRadius(6) .cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4) .opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid) .disabled(!isValid)
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked) Text(Strings.bookmarked)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
@@ -357,7 +357,10 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .padding(.leading, 8)
} .accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
) { ) {
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh } let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty { if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
@@ -399,6 +402,8 @@ struct LocationChannelsSheet: View {
titleColor: Color? = nil, titleColor: Color? = nil,
titleBold: Bool = false, titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
@@ -409,17 +414,17 @@ struct LocationChannelsSheet: View {
Text(parts.base) Text(parts.base)
.bitchatFont(size: 14) .bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary) .foregroundColor(titleColor ?? palette.primary)
if let count = parts.countSuffix, !count.isEmpty { if let count = parts.countSuffix, !count.isEmpty {
Text(count) Text(count)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull) Text(subtitleFull)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
@@ -434,6 +439,19 @@ struct LocationChannelsSheet: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture(perform: action) .onTapGesture(perform: action)
// The row is a plain HStack with a tap gesture, which VoiceOver reads
// as disconnected static text. Expose it as one activatable button;
// the visible bookmark accessory is mirrored as a named action.
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))"))
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
.accessibilityHint(Strings.switchChannelHint)
.accessibilityAction(.default, action)
.accessibilityActions {
if let accessoryActionTitle, let accessoryAction {
Button(accessoryActionTitle, action: accessoryAction)
}
}
} }
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]" // Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -477,16 +495,16 @@ extension LocationChannelsSheet {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle) Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold) .bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary) .foregroundColor(palette.primary)
Text(Strings.torSubtitle) Text(Strings.torSubtitle)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
} }
.padding(12) .padding(12)
.background(Color.secondary.opacity(0.12)) .background(palette.secondary.opacity(0.12))
.cornerRadius(8) .cornerRadius(8)
} }
+7 -14
View File
@@ -30,7 +30,6 @@ struct LocationNotesView: View {
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings { private enum Strings {
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
static let description: LocalizedStringKey = "location_notes.description" static let description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
} }
private var closeButton: some View { private var closeButton: some View {
Button(action: { dismiss() }) { SheetCloseButton { dismiss() }
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.closeAccessibility)
} }
private var headerSection: some View { private var headerSection: some View {
@@ -126,12 +119,12 @@ struct LocationNotesView: View {
} }
Text(Strings.description) Text(Strings.description)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays { if manager.state == .noRelays {
Text(Strings.relaysPaused) Text(Strings.relaysPaused)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -180,7 +173,7 @@ struct LocationNotesView: View {
if !ts.isEmpty { if !ts.isEmpty {
Text(ts) Text(ts)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
Spacer() Spacer()
} }
@@ -197,7 +190,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint) Text(Strings.relaysRetryHint)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Button(Strings.retry) { manager.refresh() } Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12) .bitchatFont(size: 12)
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -210,7 +203,7 @@ struct LocationNotesView: View {
ProgressView() ProgressView()
Text(Strings.loadingNotes) Text(Strings.loadingNotes)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Spacer() Spacer()
} }
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -222,7 +215,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle) Text(Strings.emptySubtitle)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
} }
+136 -28
View File
@@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage
#endif #endif
struct BlockRevealImageView: View { struct BlockRevealImageView: View {
@ThemedPalette private var palette
private let url: URL private let url: URL
private let revealProgress: Double? private let revealProgress: Double?
private let isSending: Bool private let isSending: Bool
@@ -20,6 +21,25 @@ struct BlockRevealImageView: View {
@State private var platformImage: PlatformImage? @State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1 @State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false @State private var isBlurred: Bool = false
@State private var showDeleteConfirmation = false
@State private var loadFailed = false
private enum Strings {
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
}
init( init(
url: URL, url: URL,
@@ -69,20 +89,32 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35)) .fill(Color.black.opacity(0.35))
.overlay( .overlay(
Image(systemName: "eye.slash.fill") VStack(spacing: 6) {
.font(.bitchatSystem(size: 24, weight: .semibold)) Image(systemName: "eye.slash.fill")
.foregroundColor(.white.opacity(0.85)) .font(.bitchatSystem(size: 24, weight: .semibold))
Text(verbatim: Strings.tapToReveal)
// Themed: monospaced under matrix,
// system under liquid glass.
.bitchatFont(size: 12, weight: .medium)
}
.foregroundColor(.white.opacity(0.85))
) )
} }
} }
} else { } else {
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.gray.opacity(0.2)) .fill(palette.secondary.opacity(0.2))
.frame(height: 200) .frame(height: 200)
.overlay( .overlay {
ProgressView() if loadFailed {
.progressViewStyle(.circular) Image(systemName: "photo")
) .font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(palette.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
} }
if let onCancel = onCancel, isSending { if let onCancel = onCancel, isSending {
@@ -95,6 +127,7 @@ struct BlockRevealImageView: View {
.padding(8) .padding(8)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
} }
} }
.onAppear { .onAppear {
@@ -106,30 +139,105 @@ struct BlockRevealImageView: View {
loadImage() loadImage()
} }
.gesture(mainGesture) .gesture(mainGesture)
} .contextMenu {
if isSending {
private func loadImage() { cancelSendAction
DispatchQueue.global(qos: .userInitiated).async { } else {
#if os(iOS) imageActions
guard let image = UIImage(contentsOfFile: url.path) else { return } }
#else }
guard let image = NSImage(contentsOf: url) else { return } .confirmationDialog(
#endif Strings.deleteConfirmTitle,
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1 isPresented: $showDeleteConfirmation,
DispatchQueue.main.async { titleVisibility: .visible
self.platformImage = image ) {
self.aspectRatio = ratio Button(Strings.delete, role: .destructive) {
onDelete?()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text(verbatim: Strings.deleteConfirmMessage)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabelText)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
.accessibilityActions {
if isSending {
// children: .ignore collapses the visible cancel button, so
// expose it as an action while the send is in flight.
cancelSendAction
} else {
imageActions
} }
} }
} }
private var mainGesture: some Gesture { @ViewBuilder
let doubleTap = TapGesture(count: 2).onEnded { private var cancelSendAction: some View {
guard !isSending else { return } if let onCancel {
onDelete?() Button(Strings.cancelSend, action: onCancel)
} }
}
@ViewBuilder
private var imageActions: some View {
// Open/reveal/hide would act on a file that failed to load, so only
// offer delete (when available) to let users clean up the attachment.
if !loadFailed {
if isBlurred {
Button(Strings.reveal) {
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
}
} else {
Button(Strings.open) { onOpen?() }
Button(Strings.hide) {
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
}
}
}
if onDelete != nil {
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
}
}
private var accessibilityLabelText: String {
if isSending { return Strings.sendingImage }
if loadFailed { return Strings.unavailableImage }
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
}
private var accessibilityHintText: String {
if isSending || loadFailed { return "" }
return isBlurred ? Strings.revealHint : Strings.openHint
}
private func loadImage() {
loadFailed = false
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
let image = UIImage(contentsOfFile: url.path)
#else
let image = NSImage(contentsOf: url)
#endif
DispatchQueue.main.async {
guard let image else {
self.loadFailed = true
return
}
self.platformImage = image
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1
}
}
}
// Double-tap used to permanently delete the image the most ingrained
// photo gesture on mobile, racing the reveal tap, with no confirmation
// and no way to get the file back. Delete now lives in the context menu
// behind a confirmation; taps only reveal and open.
private var mainGesture: some Gesture {
let singleTap = TapGesture().onEnded { let singleTap = TapGesture().onEnded {
guard !isSending else { return } guard !isSending, !loadFailed else { return }
if isBlurred { if isBlurred {
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false isBlurred = false
@@ -139,7 +247,7 @@ struct BlockRevealImageView: View {
} }
} }
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
guard !isSending else { return } guard !isSending, !loadFailed else { return }
let horizontal = value.translation.width let horizontal = value.translation.width
let vertical = value.translation.height let vertical = value.translation.height
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return } guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
@@ -149,7 +257,7 @@ struct BlockRevealImageView: View {
} }
} }
} }
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe) return singleTap.simultaneously(with: swipe)
} }
} }
+82 -33
View File
@@ -11,6 +11,7 @@ import BitFoundation
struct MediaMessageView: View { struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
let media: BitchatMessage.Media let media: BitchatMessage.Media
@@ -20,6 +21,7 @@ struct MediaMessageView: View {
/// fields by identity, so without the snapshot a status-only change /// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row. /// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus? private let deliveryStatus: DeliveryStatus?
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL? @Binding var imagePreviewURL: URL?
@@ -35,46 +37,93 @@ struct MediaMessageView: View {
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) { // Baseline alignment (via the header text inside the VStack) keeps the
HStack(alignment: .center, spacing: 4) { // lock on the header line; a fixed top padding left its solid body
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) // hanging below the line's visual center.
.fixedSize(horizontal: false, vertical: true) HStack(alignment: .firstTextBaseline, spacing: 0) {
.frame(maxWidth: .infinity, alignment: .leading) if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus { let status = deliveryStatus {
DeliveryStatusView(status: status) if case .failed = status {
.padding(.leading, 4) Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} }
}
Group { Group {
switch media { switch media {
case .voice(let url): case .voice(let url):
VoiceNoteView( VoiceNoteView(
url: url, url: url,
isSending: state.isSending, isSending: state.isSending,
sendProgress: state.progress, sendProgress: state.progress,
onCancel: cancelAction onCancel: cancelAction
) )
case .image(let url): case .image(let url):
BlockRevealImageView( BlockRevealImageView(
url: url, url: url,
revealProgress: state.progress, revealProgress: state.progress,
isSending: state.isSending, isSending: state.isSending,
onCancel: cancelAction, onCancel: cancelAction,
initiallyBlurred: !isFromMe, initiallyBlurred: !isFromMe,
onOpen: { onOpen: {
if !state.isSending { if !state.isSending {
imagePreviewURL = url imagePreviewURL = url
} }
}, },
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
) )
.frame(maxWidth: 280) .frame(maxWidth: 280)
}
} }
} }
} }
.padding(.vertical, 4) .padding(.vertical, 4)
// Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another.
.onChange(of: deliveryStatus) { _ in
showDeliveryDetail = false
}
} }
private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) { private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
@@ -90,7 +139,7 @@ struct MediaMessageView: View {
isSending = true isSending = true
progress = Double(reached) / Double(total) progress = Double(reached) / Double(total)
} }
case .sent, .read, .delivered, .failed: case .sent, .carried, .read, .delivered, .failed:
break break
} }
} }
+13 -2
View File
@@ -28,7 +28,9 @@ struct VoiceNoteView: View {
} }
private var backgroundColor: Color { private var backgroundColor: Color {
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white // Palette-based and slightly translucent so the card doesn't sit as
// an opaque white/black box over the glass gradient.
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
} }
private var borderColor: Color { private var borderColor: Color {
@@ -50,6 +52,12 @@ struct VoiceNoteView: View {
.background(Circle().fill(palette.accent)) .background(Circle().fill(palette.accent))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(
playback.isPlaying
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
)
.accessibilityValue(playbackLabel)
WaveformView( WaveformView(
samples: samples, samples: samples,
@@ -63,7 +71,7 @@ struct VoiceNoteView: View {
Text(playbackLabel) Text(playbackLabel)
.bitchatFont(size: 13) .bitchatFont(size: 13)
.foregroundColor(Color.secondary) .foregroundColor(palette.secondary)
if let onCancel = onCancel, isSending { if let onCancel = onCancel, isSending {
Button(action: onCancel) { Button(action: onCancel) {
@@ -74,6 +82,9 @@ struct VoiceNoteView: View {
.foregroundColor(.white) .foregroundColor(.white)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
} }
} }
.padding(12) .padding(12)
+1 -1
View File
@@ -42,7 +42,7 @@ struct WaveformView: View {
} else if let send = clampedSend, binPosition <= send { } else if let send = clampedSend, binPosition <= send {
color = palette.accentBlue color = palette.accentBlue
} else { } else {
color = Color.gray.opacity(0.35) color = palette.secondary.opacity(0.35)
} }
context.fill(Path(rect), with: .color(color)) context.fill(Path(rect), with: .color(color))
} }
+100 -1
View File
@@ -7,6 +7,9 @@ struct MeshPeerList: View {
let onTapPeer: (PeerID) -> Void let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void let onShowFingerprint: (PeerID) -> Void
/// Optional so existing call sites (and previews/tests) keep compiling;
/// when absent the block/unblock context-menu entry is hidden.
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = [] @State private var orderedIDs: [String] = []
@@ -15,6 +18,20 @@ struct MeshPeerList: View {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator") static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
} }
var body: some View { var body: some View {
@@ -49,21 +66,25 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
.help(Strings.connected)
} else if peer.isReachable { } else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon // Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
.help(Strings.reachable)
} else if peer.isMutualFavorite { } else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple) // Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe") Image(systemName: "globe")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.purple) .foregroundColor(.purple)
.help(Strings.nostr)
} else { } else {
// Fallback icon for others (dimmed) // Fallback icon for others (dimmed)
Image(systemName: "person") Image(systemName: "person")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary) .foregroundColor(palette.secondary)
.help(Strings.offline)
} }
let (base, suffix) = peer.displayName.splitSuffix() let (base, suffix) = peer.displayName.splitSuffix()
@@ -71,6 +92,8 @@ struct MeshPeerList: View {
Text(base) Text(base)
.bitchatFont(size: 14) .bitchatFont(size: 14)
.foregroundColor(baseColor) .foregroundColor(baseColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix) Text(suffix)
@@ -91,6 +114,10 @@ struct MeshPeerList: View {
if let icon = peer.encryptionStatus.icon { if let icon = peer.encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
// Optical centering: lock glyph ink is
// bottom-heavy, so geometric centering
// reads low next to the name.
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} else { } else {
@@ -103,6 +130,7 @@ struct MeshPeerList: View {
// Fallback to whatever status says (likely lock if we had a past session) // Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} }
@@ -123,6 +151,11 @@ struct MeshPeerList: View {
Image(systemName: peer.isFavorite ? "star.fill" : "star") Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary) .foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
// Widen the tap target beyond the bare glyph;
// height stays row-bound so neighboring rows
// keep their own taps.
.frame(width: 36)
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -131,8 +164,53 @@ struct MeshPeerList: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0) .padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } } // count:2 must attach before count:1 or the single tap
// shadows it (same ordering the header logo relies on).
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } } .onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
.contextMenu {
if !isMe {
Button(Strings.directMessage) {
onTapPeer(peer.peerID)
}
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
if peer.isBlocked {
Button(Strings.unblock) {
onToggleBlock(peer)
}
} else {
Button(Strings.block, role: .destructive) {
onToggleBlock(peer)
}
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: peer))
.accessibilityAddTraits(isMe ? [] : .isButton)
.accessibilityHint(isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !isMe {
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
onToggleBlock(peer)
}
}
}
}
} }
} }
// Seed and update order outside result builder // Seed and update order outside result builder
@@ -147,4 +225,25 @@ struct MeshPeerList: View {
} }
} }
} }
/// One spoken sentence per row: name, how they're reachable, and any
/// state badges the visual row is icon soup for VoiceOver otherwise.
private func accessibilityDescription(for peer: MeshPeerRow) -> String {
var parts: [String] = [peer.displayName]
if !peer.isMe {
if peer.isConnected {
parts.append(Strings.connected)
} else if peer.isReachable {
parts.append(Strings.reachable)
} else if peer.isMutualFavorite {
parts.append(Strings.nostr)
} else {
parts.append(Strings.offline)
}
}
if peer.isFavorite { parts.append(Strings.favorite) }
if peer.hasUnread { parts.append(Strings.unread) }
if peer.isBlocked { parts.append(Strings.blocked) }
return parts.joined(separator: ", ")
}
} }
+241 -24
View File
@@ -36,8 +36,17 @@ struct MessageListView: View {
var isTextFieldFocused: FocusState<Bool>.Binding var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false @State private var showMessageActions = false
@State private var showClearConfirmation = false
@State private var lastScrollTime: Date = .distantPast @State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer? @State private var scrollThrottleTimer: Timer?
@State private var unseenCount = 0
@State private var lastSeenMessageCount = 0
/// Context key the unseen counters were baselined against. Channel
/// switches swap the timeline wholesale, so a count delta is only a
/// "new messages" signal while the context is unchanged.
@State private var unseenBaselineKey = ""
@ThemedPalette private var palette
var body: some View { var body: some View {
let currentWindowCount: Int = { let currentWindowCount: Int = {
@@ -65,6 +74,9 @@ struct MessageListView: View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState
}
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in ForEach(messageItems) { item in
let message = item.message let message = item.message
@@ -72,6 +84,7 @@ struct MessageListView: View {
.onAppear { .onAppear {
if message.id == windowedMessages.last?.id { if message.id == windowedMessages.last?.id {
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
if message.id == windowedMessages.first?.id, if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count { messages.count > windowedMessages.count {
@@ -89,13 +102,32 @@ struct MessageListView: View {
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu { .contextMenu {
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
if showsUserActions {
// Mention and DM are redundant inside a 1:1 conversation:
// mentioning the only other participant is noise, and "DM"
// would just reopen the conversation that is already open.
if privatePeer == nil {
Button("content.actions.mention") {
insertMention(message.sender)
}
if let peerID = message.senderPeerID {
Button("content.actions.direct_message") {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
}
Button("content.actions.hug") {
conversationUIModel.sendHug(to: message.sender)
}
Button("content.actions.slap") {
conversationUIModel.sendSlap(to: message.sender)
}
}
Button("content.message.copy") { Button("content.message.copy") {
#if os(iOS) #if os(iOS)
UIPasteboard.general.string = message.content UIPasteboard.general.string = message.content
@@ -105,6 +137,16 @@ struct MessageListView: View {
pb.setString(message.content, forType: .string) pb.setString(message.content, forType: .string)
#endif #endif
} }
if isResendableFailedMessage(message) {
Button("content.actions.resend") {
conversationUIModel.resendFailedPrivateMessage(message)
}
}
if showsUserActions {
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
}
}
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 1) .padding(.vertical, 1)
@@ -113,9 +155,24 @@ struct MessageListView: View {
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2) .padding(.vertical, 2)
} }
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
jumpToLatestPill(proxy: proxy)
}
}
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
conversationUIModel.clearCurrentConversation() showClearConfirmation = true
}
.confirmationDialog(
"content.clear.confirm_title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("content.clear.confirm_action", role: .destructive) {
conversationUIModel.clearCurrentConversation()
}
Button("common.cancel", role: .cancel) {}
} }
.onAppear { .onAppear {
scrollToBottom(on: proxy) scrollToBottom(on: proxy)
@@ -139,9 +196,7 @@ struct MessageListView: View {
) { ) {
Button("content.actions.mention") { Button("content.actions.mention") {
if let sender = selectedMessageSender { if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field insertMention(sender)
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
} }
} }
@@ -208,6 +263,142 @@ struct MessageListView: View {
} }
private extension MessageListView { private extension MessageListView {
var currentContextKey: String {
if let peer = privatePeer {
return "dm:\(peer)"
}
return locationChannelsModel.selectedChannel.contextKey
}
/// Terminal-styled narration for an empty public timeline: says which
/// channel this is, that the app is waiting for peers, and where to go
/// next. Rendered inside the ScrollView; disappears with the first row.
var publicEmptyState: some View {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
case .location(let channel):
emptyStateLine(
String(
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
locale: .current,
channel.geohash
)
)
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
}
}
.padding(.horizontal, 12)
.padding(.top, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
func emptyStateLine(_ text: String) -> some View {
// Non-breaking space before the closing asterisk so a tight wrap
// can't orphan a lone "*" onto its own line.
Text(verbatim: "* \(text)\u{00A0}*")
.bitchatFont(size: 13)
.foregroundColor(palette.secondary.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
/// Messages the unseen counters may book as "new": rows that render as
/// human messages. System lines render as narration and whitespace-only
/// content never renders at all, so neither belongs in the pill count.
func unseenEligibleCount(in messages: [BitchatMessage]) -> Int {
messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count
}
/// Updates the unseen-count baseline for the current context and returns
/// how many messages were appended since the last observation. A context
/// change (timeline swapped wholesale) re-baselines and reports zero, so
/// cross-channel count differences are never booked as "new" messages.
func rebaselinedAppendedCount(newCount: Int) -> Int {
let key = currentContextKey
if unseenBaselineKey != key {
unseenBaselineKey = key
unseenCount = 0
lastSeenMessageCount = newCount
return 0
}
let appended = max(0, newCount - lastSeenMessageCount)
lastSeenMessageCount = newCount
return appended
}
/// A failed private text message of our own can be resent through the
/// normal send path (the context menu removes the failed original and
/// re-submits its content).
func isResendableFailedMessage(_ message: BitchatMessage) -> Bool {
guard message.isPrivate,
conversationUIModel.isSentByCurrentUser(message),
conversationUIModel.mediaAttachment(for: message) == nil,
case .some(.failed) = message.deliveryStatus
else { return false }
return true
}
/// Appends an @mention to the composer draft (never overwrites what the
/// user has already typed) and focuses the input field.
func insertMention(_ sender: String) {
let mention = "@\(sender) "
if messageText.isEmpty {
messageText = mention
} else if messageText.hasSuffix(" ") {
messageText += mention
} else {
messageText += " " + mention
}
isTextFieldFocused.wrappedValue = true
}
/// Floating pill shown while scrolled up: re-presents the isAtBottom /
/// unseenCount state the view already tracks, and jumps to the newest
/// message via the existing scrollToBottom helper.
func jumpToLatestPill(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(on: proxy)
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.down")
.font(.bitchatSystem(size: 11, weight: .semibold))
if unseenCount > 0 {
Text(
String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
)
.bitchatFont(size: 12, weight: .medium)
}
}
.foregroundColor(palette.primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.themedOverlayPanel()
.padding(.trailing, 12)
.padding(.bottom, 10)
.accessibilityLabel(jumpToLatestAccessibilityLabel)
}
var jumpToLatestAccessibilityLabel: String {
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
guard unseenCount > 0 else { return base }
let count = String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
return "\(base), \(count)"
}
@ViewBuilder @ViewBuilder
func messageRow(for message: BitchatMessage) -> some View { func messageRow(for message: BitchatMessage) -> some View {
Group { Group {
@@ -294,6 +485,9 @@ private extension MessageListView {
func scrollToBottom(on proxy: ScrollViewProxy) { func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true isAtBottom = true
unseenCount = 0
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
unseenBaselineKey = currentContextKey
if let targetPeerID { if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom) proxy.scrollTo(targetPeerID, anchor: .bottom)
} }
@@ -316,15 +510,23 @@ private extension MessageListView {
} }
func onMessagesChange(proxy: ScrollViewProxy) { func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil else { return }
let messages = publicChatModel.messages let messages = publicChatModel.messages
guard privatePeer == nil, let lastMsg = messages.last else { return } let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
guard let lastMsg = messages.last else {
// Timeline emptied (e.g. /clear): nothing below to jump to.
unseenCount = 0
return
}
// If the newest message is from me, always scroll to bottom // If the newest message is from me, always scroll to bottom
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return return
} else { // Ensure we consider ourselves at bottom for subsequent messages } else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
@@ -352,18 +554,23 @@ private extension MessageListView {
} }
func onPrivateChatsChange(proxy: ScrollViewProxy) { func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer, guard let peerID = privatePeer else { return }
let lastMsg = privateInboxModel.messages(for: peerID).last else { let messages = privateInboxModel.messages(for: peerID)
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
guard let lastMsg = messages.last else {
// Timeline emptied (e.g. /clear): nothing below to jump to.
unseenCount = 0
return return
} }
let messages = privateInboxModel.messages(for: peerID)
// If the newest private message is from me, always scroll // If the newest private message is from me, always scroll
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return return
} else { } else {
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
@@ -391,17 +598,27 @@ private extension MessageListView {
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) { func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom // When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return } guard privatePeer == nil else { return }
// Invalidate the unseen baseline: the timeline is about to swap (or
// already has the ordering of this onChange vs the count onChange
// is not guaranteed), so the next count observation re-baselines
// instead of booking the cross-channel difference as "new".
unseenCount = 0
unseenBaselineKey = ""
// Entering any public channel shows its latest messages: a channel
// switch swaps the timeline wholesale, so the prior scroll offset is
// meaningless. Landing at the bottom keeps isAtBottom honest (no
// stale jump-to-latest pill) and matches standard chat behavior.
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey: String
switch channel { switch channel {
case .mesh: case .mesh:
break contextKey = "mesh"
case .location(let ch): case .location(let ch):
// Reset window size contextKey = "geo:\(ch.geohash)"
isAtBottom = true }
windowCountPublic = TransportConfig.uiWindowInitialCountPublic if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
let contextKey = "geo:\(ch.geohash)" proxy.scrollTo(target, anchor: .bottom)
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
} }
} }
@@ -426,6 +643,6 @@ private extension ChannelID {
} }
} }
//#Preview { // #Preview {
// MessageListView() // MessageListView()
//} // }
+13 -12
View File
@@ -11,7 +11,10 @@ import AppKit
struct MyQRView: View { struct MyQRView: View {
let qrString: String let qrString: String
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
private var boxColor: Color { Color.gray.opacity(0.1) } @ThemedPalette private var palette
// Palette-tinted so the box follows the theme (green under matrix)
// instead of a fixed gray band over the glass gradient.
private var boxColor: Color { palette.secondary.opacity(0.1) }
private enum Strings { private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title" static let title: LocalizedStringKey = "verification.my_qr.title"
@@ -50,6 +53,7 @@ struct MyQRView: View {
struct QRCodeImage: View { struct QRCodeImage: View {
let data: String let data: String
let size: CGFloat let size: CGFloat
@ThemedPalette private var palette
private let context = CIContext() private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator() private let filter = CIFilter.qrCodeGenerator()
@@ -65,12 +69,12 @@ struct QRCodeImage: View {
.frame(width: size, height: size) .frame(width: size, height: size)
} else { } else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1) .stroke(palette.secondary.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Text(Strings.unavailable) Text(Strings.unavailable)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.gray) .foregroundColor(palette.secondary)
) )
} }
} }
@@ -108,6 +112,7 @@ struct ImageWrapper: View {
/// Placeholder scanner UI; real camera scanning will be added later. /// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View { struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = "" @State private var input = ""
@@ -153,7 +158,7 @@ struct QRScanView: View {
.bitchatFont(size: 14, weight: .medium) .bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input) TextEditor(text: $input)
.frame(height: 100) .frame(height: 100)
.border(Color.gray.opacity(0.4)) .border(palette.secondary.opacity(0.4))
Button(Strings.validate) { Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR // Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else { guard input != lastValid else {
@@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable {
} }
final class PreviewView: UIView { final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -285,7 +290,7 @@ struct VerificationSheetView: View {
private var backgroundColor: Color { palette.background } private var backgroundColor: Color { palette.background }
private var accentColor: Color { palette.accent } private var accentColor: Color { palette.accent }
private var boxColor: Color { Color.gray.opacity(0.1) } private var boxColor: Color { palette.secondary.opacity(0.1) }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -295,15 +300,11 @@ struct VerificationSheetView: View {
.bitchatFont(size: 14, weight: .bold) .bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor) .foregroundColor(accentColor)
Spacer() Spacer()
Button(action: { SheetCloseButton {
showingScanner = false showingScanner = false
isPresented = false isPresented = false
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.foregroundColor(accentColor)
} }
.buttonStyle(.plain) .foregroundColor(accentColor)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 12) .padding(.top, 12)
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
self.loadFirstPlainText(from: providers) { text in self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty { if let t = text, !t.isEmpty {
// Treat as URL if parseable http(s), else plain text // Treat as URL if parseable http(s), else plain text
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") { if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") {
self.saveAndFinish(url: u, title: item.attributedTitle?.string) self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else { } else {
self.saveAndFinish(text: t) self.saveAndFinish(text: t)
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
context.meshNicknamesByPeerID = [ context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice", PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob", PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me", PeerID(str: "3333333333333333"): "me"
] ]
// Matching query: suggestions and range are published, index resets. // Matching query: suggestions and range are published, index resets.
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
"aaaabbbbccccdddd": "carol", "aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy // Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222". // identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me", "ffffeeeeddddcccc2222": "me"
] ]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3) coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
// Same message under both keys: the read copy must win over sent. // Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent), makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2), makePrivateMessage(id: "m2", timestamp: t2)
] ]
context.privateChats[stablePeerID] = [ context.privateChats[stablePeerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)), makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
] ]
let merged = coordinator.getPrivateChatMessages(for: peerID) let merged = coordinator.getPrivateChatMessages(for: peerID)
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
makePrivateMessage(id: "m1", senderPeerID: convKey), makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey), makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true), makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID), makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
] ]
coordinator.markPrivateMessagesAsRead(from: convKey) coordinator.markPrivateMessagesAsRead(from: convKey)
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
) )
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: peerID), makePrivateMessage(id: "in-1", senderPeerID: peerID),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true), makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
] ]
coordinator.markPrivateMessagesAsRead(from: peerID) coordinator.markPrivateMessagesAsRead(from: peerID)
@@ -608,34 +608,6 @@ struct GeoPresenceTrackerTests {
#expect(stamped > stale) #expect(stamped > stale)
#expect(context.appendedGeohashMessages.count == 1) #expect(context.appendedGeohashMessages.count == 1)
} }
@Test @MainActor
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
let noiseKey = Data(repeating: 0x42, count: 32)
// The favorites store bridges the sender's npub back to a Noise key.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: sender.npub
)
coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
#expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex)
#expect(context.addedFavorites.first?.nickname == "alice")
#expect(context.postedLocalNotifications.count == 1)
#expect(context.postedLocalNotifications.first?.title == "New Favorite")
#expect(context.postedLocalNotifications.first?.body == "alice favorited you")
// Unfavorite: no store write, but the removal notification still posts.
coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.postedLocalNotifications.last?.title == "Favorite Removed")
#expect(context.postedLocalNotifications.last?.body == "alice unfavorited you")
}
@Test @MainActor @Test @MainActor
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws { func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
peerID: currentPeer, peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32), noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice" nickname: "alice"
), )
] ]
context.unreadPrivateMessages = [ context.unreadPrivateMessages = [
currentPeer, currentPeer,
staleShortPeer, staleShortPeer,
geoDMWithMessages, geoDMWithMessages,
geoDMWithoutMessages, geoDMWithoutMessages,
noiseKeyWithMessages, noiseKeyWithMessages
] ]
context.privateChats = [ context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")], geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")], noiseKeyWithMessages: [makeMessage(id: "noise-1")]
] ]
coordinator.didUpdatePeerList([currentPeer]) coordinator.didUpdatePeerList([currentPeer])
@@ -225,6 +225,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
favoriteRelationshipsByNoiseKey[noiseKey] favoriteRelationshipsByNoiseKey[noiseKey]
} }
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value
}
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey)) peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
} }
@@ -352,7 +356,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
context.displayNamesByPubkey[senderPubkey] = "alice#1234" context.displayNamesByPubkey[senderPubkey] = "alice#1234"
context.privateChats[convKey] = [ context.privateChats[convKey] = [
makeIncomingMessage(id: "mine-1", sender: "me"), makeIncomingMessage(id: "mine-1", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me"), makeIncomingMessage(id: "mine-2", sender: "me")
] ]
coordinator.handleDelivered( coordinator.handleDelivered(
@@ -549,14 +553,14 @@ struct ChatPrivateConversationCoordinatorContextTests {
} }
@Test @MainActor @Test @MainActor
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async { func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async {
let context = MockChatPrivateConversationContext() let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context) let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32) let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey) let peerID = PeerID(hexData: noiseKey)
// First [FAVORITED] flips theyFavoritedUs: store write + announcement. // First [FAVORITED] flips theyFavoritedUs: store write + announcement.
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 1) #expect(context.peerFavoritedUsUpdates.count == 1)
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
#expect(context.peerFavoritedUsUpdates.first?.favorited == true) #expect(context.peerFavoritedUsUpdates.first?.favorited == true)
@@ -568,16 +572,79 @@ struct ChatPrivateConversationCoordinatorContextTests {
noiseKey: noiseKey, noiseKey: noiseKey,
theyFavoritedUs: true theyFavoritedUs: true
) )
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 2) #expect(context.peerFavoritedUsUpdates.count == 2)
#expect(context.meshOnlySystemMessages == ["alice favorited you"]) #expect(context.meshOnlySystemMessages == ["alice favorited you"])
// [UNFAVORITED] transition announces again. // [UNFAVORITED] transition announces again.
coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice") coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.last?.favorited == false) #expect(context.peerFavoritedUsUpdates.last?.favorited == false)
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"]) #expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
} }
/// A Nostr DM whose sender resolved to a known noise key must be labeled
/// with the favorite's nickname, not the geohash-scoped anon fallback.
@Test @MainActor
func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xDA, count: 32)
let convKey = PeerID(hexData: noiseKey)
let senderPubkey = "0badc0de00112233"
// No displayNamesByPubkey entry: the geo fallback would be "anon".
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1bob",
nickname: "bob",
isFavorite: true,
theyFavoritedUs: true
)
let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()!
let payload = NoisePayload(type: .privateMessage, data: payloadData)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date()
)
#expect(context.privateChats[convKey]?.first?.sender == "bob")
}
/// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey
/// path; they must update the relationship, not render as chat text.
@Test @MainActor
func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xEE, count: 32)
// The inbound pipeline resolves known favorites to their noise-key ID.
let convKey = PeerID(hexData: noiseKey)
let senderPubkey = "feedface99887766"
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()!
let payload = NoisePayload(type: .privateMessage, data: payloadData)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date()
)
#expect(context.peerFavoritedUsUpdates.count == 1)
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
#expect(context.privateChats[convKey, default: []].isEmpty)
#expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"])
}
@Test @MainActor @Test @MainActor
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async { func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
let context = MockChatPrivateConversationContext() let context = MockChatPrivateConversationContext()
@@ -602,6 +669,32 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.systemMessages.isEmpty) #expect(context.systemMessages.isEmpty)
} }
/// Same as above, but the conversation is keyed by the SHORT mesh ID
/// the DM window was opened while the peer was on mesh, then they went
/// out of range. The favorite must resolve via the derived short ID and
/// route over Nostr instead of failing "peer not reachable".
@Test @MainActor
func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xCE, count: 32)
let shortID = PeerID(publicKey: noiseKey)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1bob",
nickname: "bob",
isFavorite: true,
theyFavoritedUs: true
)
coordinator.sendPrivateMessage("hello again", to: shortID)
#expect(context.routedPrivateMessages.map(\.content) == ["hello again"])
#expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent)
#expect(context.privateChats[shortID]?.first?.recipientNickname == "bob")
#expect(context.systemMessages.isEmpty)
}
@Test @MainActor @Test @MainActor
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async { func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
let context = MockChatPrivateConversationContext() let context = MockChatPrivateConversationContext()
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID), makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID), makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
] ]
coordinator.didDisconnectFromPeer(peerID) coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks() await drainMainActorTasks()
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.unreadPrivateMessages = [peerID] context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID), makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
] ]
coordinator.didDisconnectFromPeer(peerID) coordinator.didDisconnectFromPeer(peerID)
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor @Test @MainActor
func statusRank_orderingIsCorrect() async { func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages // This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read // failed < sending < sent < carried < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.failed(reason: "test"), .failed(reason: "test"),
.sending, .sending,
.sent, .sent,
.carried,
.partiallyDelivered(reached: 1, total: 3), .partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()), .delivered(to: "B", at: Date()),
.read(by: "C", at: Date()) .read(by: "C", at: Date())
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
case .failed: #expect(index == 0) case .failed: #expect(index == 0)
case .sending: #expect(index == 1) case .sending: #expect(index == 1)
case .sent: #expect(index == 2) case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3) case .carried: #expect(index == 3)
case .delivered: #expect(index == 4) case .partiallyDelivered: #expect(index == 4)
case .read: #expect(index == 5) case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
} }
} }
} }
+49 -14
View File
@@ -297,8 +297,23 @@ struct ChatViewModelNostrExtensionTests {
let didAppend = await TestHelpers.waitUntil({ let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Hello Geo" } if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
}) // LocationChannelManager is a process-wide singleton: a suite
// running in parallel (e.g. CommandProcessorTests) can flip the
// selected channel mid-test, which reroutes or drops the event
// permanently no amount of waiting recovers it. Re-assert the
// channel and redeliver on each poll: every channel switch clears
// the processed-event set and the store dedups by message ID, so
// redelivery is idempotent and interference heals on the next
// poll while a genuine failure still times out.
if LocationChannelManager.shared.selectedChannel != channel {
LocationChannelManager.shared.select(channel)
}
if viewModel.activeChannel == channel {
viewModel.handleNostrEvent(signed)
}
return false
}, timeout: TestConstants.longTimeout)
#expect(didAppend) #expect(didAppend)
} }
@@ -640,8 +655,10 @@ struct ChatViewModelNostrExtensionTests {
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey) #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
} }
/// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
/// out of the conversation transcript.
@Test @MainActor @Test @MainActor
func handleFavoriteNotification_updatesFavoriteAssociation() async throws { func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate() let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) }) let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
@@ -649,19 +666,33 @@ struct ChatViewModelNostrExtensionTests {
FavoritesPersistenceService.shared.addFavorite( FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey, peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub, peerNostrPublicKey: identity.npub,
peerNickname: "Before" peerNickname: "Alice"
) )
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } defer {
FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
}
viewModel.handleFavoriteNotification( // The inbound pipeline resolves a known sender to their noise-key ID.
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice", let convKey = PeerID(hexData: noiseKey)
from: identity.publicKeyHex let payloadData = try #require(
PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode()
)
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(
payload,
senderPubkey: identity.publicKeyHex,
convKey: convKey,
id: identity,
messageTimestamp: Date()
) )
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
#expect(relationship?.peerNickname == "Alice") #expect(relationship?.theyFavoritedUs == true)
#expect(relationship?.isMutual == true)
#expect(relationship?.peerNostrPublicKey == identity.npub) #expect(relationship?.peerNostrPublicKey == identity.npub)
#expect(relationship?.isFavorite == true) #expect(viewModel.privateChats[convKey, default: []].isEmpty)
} }
@Test @MainActor @Test @MainActor
@@ -1000,7 +1031,11 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url) viewModel.sendVoiceNote(at: url)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0) // Media sends hop through Task.detached; the global executor is
// shared with every parallel test worker, so a loaded runner can
// exceed the 5s default. waitUntil returns as soon as the condition
// holds, so passing runs never pay the longer timeout.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
#expect(didSend) #expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@@ -1020,7 +1055,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({ let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: 5.0) }, timeout: TestConstants.longTimeout)
#expect(didFail) #expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path)) #expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty) #expect(transport.sentPrivateFiles.isEmpty)
@@ -1036,7 +1071,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL) viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0) let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
#expect(didSend) #expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@@ -1057,7 +1092,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({ let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 5.0) }, timeout: TestConstants.longTimeout)
#expect(didNotify) #expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty) #expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false) #expect(viewModel.privateChats[peerID]?.isEmpty != false)
+53
View File
@@ -263,6 +263,59 @@ struct ChatViewModelCommandTests {
#expect(transport.sentPrivateMessages.isEmpty) #expect(transport.sentPrivateMessages.isEmpty)
} }
} }
@Test @MainActor
func handleCommand_outputRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/help")
#expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText)
#expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText })
}
@Test @MainActor
func handleCommand_errorRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/bogus")
let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? []
#expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") })
#expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") })
}
@Test @MainActor
func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.handleCommand("/bogus")
#expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true)
}
@Test @MainActor
func handleCommand_msgSuccessLandsInNewlyOpenedChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
let resolved = await TestHelpers.waitUntil({
viewModel.getPeerIDForNickname("Alice") == peerID
}, timeout: TestConstants.defaultTimeout)
#expect(resolved)
viewModel.handleCommand("/msg Alice")
#expect(viewModel.selectedPrivateChatPeer == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice")
#expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" })
}
} }
// MARK: - Composer Tests // MARK: - Composer Tests
+44
View File
@@ -303,6 +303,50 @@ struct CommandProcessorTests {
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) #expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
} }
/// /fav must go through toggleFavorite (which persists by the real noise
/// key) not write the hex peer ID into the favorites store, and not
/// send a second favorite notification.
@MainActor
@Test func favoriteCommandTogglesWithoutDirectStoreWrite() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(
contextProvider: context,
meshService: MockTransport(),
identityManager: identityManager
)
let peerID = PeerID(str: "00aa00bb00cc00dd")
context.nicknameToPeerID["alice"] = peerID
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/fav alice")
}
switch result {
case .success(let message):
#expect(message == "added alice to favorites")
default:
Issue.record("Expected success result")
}
#expect(context.toggledFavorites == [peerID])
#expect(context.favoriteNotifications.isEmpty)
// The 8-byte routing ID must never be stored as a "noise key".
let bogusKey = Data(hexString: peerID.id)!
#expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil)
// Unfavoriting someone who is not a favorite is a no-op.
let unfavResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/unfav alice")
}
switch unfavResult {
case .success(let message):
#expect(message == "alice is not a favorite")
default:
Issue.record("Expected success result")
}
#expect(context.toggledFavorites == [peerID])
}
@MainActor @MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async { @Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain()) let identityManager = MockIdentityManager(MockKeychain())
+176
View File
@@ -0,0 +1,176 @@
//
// CourierStoreTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct CourierStoreTests {
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
private func makeStore(now: Date = baseDate) -> CourierStore {
CourierStore(persistsToDisk: false, now: { now })
}
/// Store whose clock can be advanced by tests.
private final class Clock {
var now: Date
init(_ now: Date) { self.now = now }
}
private func makeEnvelope(
recipientKey: Data = Data(repeating: 0xB0, count: 32),
sealedAt: Date = baseDate,
lifetime: TimeInterval = 60 * 60,
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
) -> CourierEnvelope {
CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientKey,
epochDay: CourierEnvelope.epochDay(for: sealedAt)
),
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
ciphertext: ciphertext
)
}
private let depositorA = Data(repeating: 0xA1, count: 32)
private let depositorB = Data(repeating: 0xA2, count: 32)
// MARK: - Deposit and handover
@Test func depositThenTakeForRecipient() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
let taken = store.takeEnvelopes(for: recipientKey)
#expect(taken == [envelope])
// Handover removes the envelope.
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
@Test func takeIgnoresOtherRecipients() {
let store = makeStore()
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
store.deposit(envelope, from: depositorA)
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
}
@Test func duplicateDepositIsIdempotent() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
#expect(store.deposit(envelope, from: depositorA))
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
}
// MARK: - Validity
@Test func rejectsExpiredAndOversizedAndMalformed() {
let store = makeStore()
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
#expect(!store.deposit(expired, from: depositorA))
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
#expect(!store.deposit(oversized, from: depositorA))
let badTag = CourierEnvelope(
recipientTag: Data(repeating: 0, count: 4),
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
ciphertext: Data(repeating: 1, count: 16)
)
#expect(!store.deposit(badTag, from: depositorA))
}
@Test func rejectsExpiryBeyondPolicyLifetime() {
let store = makeStore()
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
#expect(!store.deposit(pinned, from: depositorA))
}
// MARK: - Quotas
@Test func perDepositorQuota() {
let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA))
}
#expect(!store.deposit(makeEnvelope(), from: depositorA))
// A different depositor still has room.
#expect(store.deposit(makeEnvelope(), from: depositorB))
}
@Test func totalQuotaEvictsOldestFirst() {
let store = makeStore()
let firstRecipient = Data(repeating: 0xD0, count: 32)
let first = makeEnvelope(recipientKey: firstRecipient)
store.deposit(first, from: depositorA)
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
var deposited = 1
var depositorByte: UInt8 = 1
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
#expect(store.deposit(makeEnvelope(), from: depositor))
deposited += 1
}
depositorByte += 1
}
// The first envelope was evicted to make room.
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
}
// MARK: - Expiry over time
@Test func expiredEnvelopesAreNotHandedOver() {
let clock = Clock(Self.baseDate)
let store = CourierStore(persistsToDisk: false, now: { clock.now })
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
clock.now = Self.baseDate.addingTimeInterval(7200)
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Panic wipe
@Test func wipeDropsEverything() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
store.wipe()
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Persistence
@Test func persistsAndReloadsAcrossInstances() throws {
// Isolated on-disk location so the test never touches the real store.
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("envelopes.json")
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
let recipientKey = Data(repeating: 0xE0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(first.deposit(envelope, from: depositorA))
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
}
}
@@ -0,0 +1,730 @@
//
// CourierEndToEndTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import Combine
import CoreBluetooth
import BitFoundation
@testable import bitchat
/// Three-node courier flow exercised through real BLEService instances with
/// packets ferried in-process: Alice deposits a sealed envelope with Carol
/// while Bob is unreachable; Carol hands it over when Bob announces; Bob
/// opens it and sees Alice's message in the right DM thread.
struct CourierEndToEndTests {
// MARK: - Helpers
private final class PacketTap {
private let lock = NSLock()
private var packets: [BitchatPacket] = []
func record(_ packet: BitchatPacket) {
lock.lock(); packets.append(packet); lock.unlock()
}
func first(ofType type: MessageType) -> BitchatPacket? {
lock.lock(); defer { lock.unlock() }
return packets.first { $0.type == type.rawValue }
}
func count(ofType type: MessageType) -> Int {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
func all(ofType type: MessageType) -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }
}
}
private final class NoiseCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
}
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
lock.lock(); defer { lock.unlock() }
return payloads
}
// Unused BitchatDelegate requirements.
func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
}
private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService {
let keychain = MockKeychain()
let identityManager = identityManager ?? MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
)
service.courierStore = CourierStore(persistsToDisk: false)
return service
}
/// Handling any packet from a peer preseeds it as a connected,
/// verified entry in the receiving service's registry.
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data("ping".utf8),
signature: nil,
ttl: 1
)
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
}
// MARK: - Tests
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
// Alice and Carol are mutual favorites; trust policy is exercised
// separately in depositFromUntrustedPeerIsRejected.
carol.courierDepositPolicy = { _ in true }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
// Alice can see Carol; Bob is nowhere on the mesh.
preseedConnectedPeer(carol, in: alice)
// 1. Alice seals to Bob's static key and deposits with Carol.
#expect(alice.sendCourierMessage(
"the camp moved north",
messageID: "courier-msg-1",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
// 3. Later, Bob announces near Carol handover fires.
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
// 4. Ferry the handover to Bob; he opens the envelope.
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(received)
let delivered = try #require(bobDelegate.snapshot().first)
#expect(delivered.type == .privateMessage)
// Alice is absent from Bob's mesh, so the sender resolves to her
// full noise-key ID the stable favorite conversation not the
// short mesh ID (which Bob couldn't resolve to a nickname) and not
// the courier's identity.
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
#expect(delivered.peerID != carol.myPeerID)
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
#expect(message.messageID == "courier-msg-1")
#expect(message.content == "the camp moved north")
}
@Test func courieredMailFromBlockedSenderIsDropped() async throws {
let alice = makeService()
let carol = makeService()
let bobIdentity = MockIdentityManager(MockKeychain())
let bob = makeService(identityManager: bobIdentity)
carol.courierDepositPolicy = { _ in true }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
preseedConnectedPeer(carol, in: alice)
// Bob blocked Alice by her stable Noise identity while she was away.
bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true)
#expect(alice.sendCourierMessage(
"you should not see this",
messageID: "courier-msg-blocked-sender",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
// Bob opens the envelope but the sealed sender is blocked, and it
// must never reach the UI. The live block check can't cover this: the
// sender is absent from Bob's registry, so no fingerprint resolves at
// delivery time.
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let delivered = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!delivered)
}
@Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
carol.courierDepositPolicy = { _ in true }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
preseedConnectedPeer(carol, in: alice)
#expect(alice.sendCourierMessage(
"hold until verified",
messageID: "courier-msg-unverified-announce",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
let forgedAnnounce = try makeUnsignedAnnounce(from: bob)
carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!leakedOnUnverifiedAnnounce)
#expect(!carol.courierStore.isEmpty)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
}
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
carol.courierDepositPolicy = { _ in true }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
preseedConnectedPeer(carol, in: alice)
#expect(alice.sendCourierMessage(
"hold for a direct encounter",
messageID: "courier-msg-relayed-announce",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let directAnnounce = try #require(bobOut.first(ofType: .announce))
// A relayed copy has a decremented TTL but a still-valid signature
// (TTL is excluded from announce signatures). Envelopes are removed
// from the store optimistically, so handover must wait for a direct
// encounter instead of chasing a multi-hop path.
var relayedAnnounce = directAnnounce
relayedAnnounce.ttl = directAnnounce.ttl - 1
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!leakedOnRelayedAnnounce)
#expect(!carol.courierStore.isEmpty)
// The relayed copy consumed the original announce's dedup key
// (sender/timestamp/payload TTL excluded), so the direct handover
// needs a fresh announce. Wait out the 1s announce throttle first.
try await Task.sleep(nanoseconds: 1_100_000_000)
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
timeout: TestConstants.defaultTimeout
)
#expect(reannounced)
let freshAnnounce = try #require(
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
)
carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
}
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
let alice = makeService()
let carol = makeService()
preseedConnectedPeer(carol, in: alice)
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
#expect(!alice.sendCourierMessage(
"this cannot be sealed",
messageID: "courier-msg-invalid-key",
recipientNoiseKey: Data(repeating: 0x01, count: 8),
via: [carol.myPeerID]
))
let queuedPacket = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.shortTimeout
)
#expect(!queuedPacket)
}
@Test func depositFromUntrustedPeerIsRejected() async throws {
let carol = makeService()
carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1"))
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
let now = Date()
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: bobKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: sealed
)
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: carol.myPeerID.id),
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: try #require(envelope.encode()),
signature: nil,
ttl: 1
)
carol._test_handlePacket(packet, fromPeerID: alicePeerID)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!stored)
}
@Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws {
let alice = makeService()
let carol = makeService()
let mallory = makeService()
preseedConnectedPeer(alice, in: carol)
preseedConnectedPeer(mallory, in: carol)
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
carol.courierDepositPolicy = { depositorKey in
depositorKey == trustedAliceKey
}
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof"))
let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
let now = Date()
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: bobKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: sealed
)
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alice.myPeerID.id) ?? Data(),
recipientID: Data(hexString: carol.myPeerID.id),
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: try #require(envelope.encode()),
signature: nil,
ttl: 1
)
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!stored)
}
private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: "Unsigned",
noisePublicKey: service.noiseStaticPublicKeyData(),
signingPublicKey: service.noiseSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
return BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
}
}
// MARK: - Router courier selection
/// Minimal transport stub for exercising MessageRouter's courier deposit
/// logic without BLE plumbing.
private final class CourierCaptureTransport: Transport {
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var snapshots: [TransportPeerSnapshot] = []
private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = []
private(set) var directSends: [String] = []
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just(snapshots).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots }
var myPeerID = PeerID(str: "00000000000000aa")
var myNickname = "stub"
func setNickname(_ nickname: String) {}
func startServices() {}
func stopServices() {}
func emergencyDisconnectAll() {}
func isPeerConnected(_ peerID: PeerID) -> Bool {
snapshots.contains { $0.peerID == peerID && $0.isConnected }
}
// Nostr-style reachability: claimed for peers with no live link (known
// npub), where prompt delivery additionally needs a relay connection.
var reachablePeers: Set<PeerID> = []
var promptDelivery = true
func isPeerReachable(_ peerID: PeerID) -> Bool {
isPeerConnected(peerID) || reachablePeers.contains(peerID)
}
func canDeliverPromptly(to peerID: PeerID) -> Bool {
isPeerReachable(peerID) && promptDelivery
}
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) {}
func sendMessage(_ content: String, mentions: [String]) {}
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
directSends.append(messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
func sendBroadcastAnnounce() {}
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
courierSends.append((messageID, recipientNoiseKey, couriers))
return true
}
}
struct MessageRouterCourierTests {
@Test @MainActor
func unreachablePeerMessageGoesToTrustedCouriersOnly() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let carolKey = Data(repeating: 0xC0, count: 32)
let carolID = PeerID(publicKey: carolKey)
let daveKey = Data(repeating: 0xD0, count: 32)
let daveID = PeerID(publicKey: daveKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
// Carol: connected mutual favorite eligible courier.
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()),
// Dave: connected but not trusted never a courier.
TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date())
]
let directory = CourierDirectory(
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
isTrustedCourier: { $0 == carolKey }
)
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1")
#expect(transport.directSends.isEmpty)
#expect(transport.courierSends.count == 1)
#expect(transport.courierSends.first?.messageID == "m1")
#expect(transport.courierSends.first?.recipientKey == bobKey)
#expect(transport.courierSends.first?.couriers == [carolID])
#expect(carried == ["m1"])
}
@Test @MainActor
func noCourierDepositWithoutKnownRecipientKey() {
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date())
]
let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true })
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2")
#expect(transport.courierSends.isEmpty)
#expect(carried.isEmpty)
}
/// The production directory must resolve both ID forms: a 64-hex
/// noise-key ID (offline favorite row) carries the key itself, and a
/// short 16-hex ID resolves through the favorites store.
@Test @MainActor
func favoritesBackedDirectoryResolvesBothIDForms() {
let directory = CourierDirectory.favoritesBacked()
let bobKey = Data(repeating: 0xB7, count: 32)
#expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey)
FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob")
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) }
#expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey)
}
@Test @MainActor
func reachablePeerSkipsCourier() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date())
]
let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true })
let router = MessageRouter(transports: [transport], courierDirectory: directory)
router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3")
#expect(transport.directSends == ["m3"])
#expect(transport.courierSends.isEmpty)
}
/// A peer can be "reachable" through a transport that cannot deliver
/// promptly (Nostr claims any favorite with a known npub, even with no
/// relay connection). The queued send must not shadow the courier: a
/// sealed copy goes to connected couriers in parallel, and receivers
/// dedup by message ID if both arrive.
@Test @MainActor
func queuedReachableSendAlsoDepositsWithCourier() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let carolKey = Data(repeating: 0xC0, count: 32)
let carolID = PeerID(publicKey: carolKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
]
transport.reachablePeers = [bobID]
transport.promptDelivery = false
let directory = CourierDirectory(
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
isTrustedCourier: { $0 == carolKey }
)
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m4")
#expect(transport.directSends == ["m4"])
#expect(transport.courierSends.count == 1)
#expect(transport.courierSends.first?.messageID == "m4")
#expect(transport.courierSends.first?.couriers == [carolID])
#expect(carried == ["m4"])
}
/// When the reachable transport can deliver promptly (relays up), the
/// send is trusted and no courier quota is spent.
@Test @MainActor
func promptlyDeliverableReachablePeerSkipsCourier() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let carolKey = Data(repeating: 0xC0, count: 32)
let carolID = PeerID(publicKey: carolKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
]
transport.reachablePeers = [bobID]
let directory = CourierDirectory(
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
isTrustedCourier: { $0 == carolKey }
)
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m5")
#expect(transport.directSends == ["m5"])
#expect(transport.courierSends.isEmpty)
#expect(carried.isEmpty)
}
}
@@ -174,7 +174,7 @@ struct IntegrationTests {
} }
// Encrypted path: use NoiseSessionManager explicitly // Encrypted path: use NoiseSessionManager explicitly
let plaintext = "Encrypted message".data(using: .utf8)! let plaintext = Data("Encrypted message".utf8)
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID) let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
@@ -206,7 +206,7 @@ struct IntegrationTests {
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages // David tracks received messages
helper.nodes["David"]!.messageDeliveryHandler = { message in helper.nodes["David"]!.messageDeliveryHandler = { _ in
completion() completion()
} }
@@ -288,7 +288,7 @@ struct IntegrationTests {
} }
do { do {
let plaintext = "After restart success".data(using: .utf8)! let plaintext = Data("After restart success".utf8)
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID) let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
@@ -121,4 +121,3 @@ final class TestNetworkHelper {
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
} }
} }
+6 -6
View File
@@ -52,19 +52,19 @@ struct MimeTypeTests {
@Test(arguments: [ @Test(arguments: [
// === Image types === // === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]), (MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP" 0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types === // === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE" 0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types === // === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
]) ])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws { func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes) let data = Data(bytes)
+1 -1
View File
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
Data(), Data(),
Data(repeating: 0x00, count: 32), Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)), Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32), Data(repeating: 0xFF, count: 32)
] ]
for invalidKey in invalidKeys { for invalidKey in invalidKeys {
+27 -28
View File
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
@Test func basicEncryptionDecryption() throws { @Test func basicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Hello, Bob!".data(using: .utf8)! let plaintext = Data("Hello, Bob!".utf8)
// Alice encrypts // Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob // Alice -> Bob
let aliceMessage = "Hello from Alice".data(using: .utf8)! let aliceMessage = Data("Hello from Alice".utf8)
let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext) let bobReceived = try bobSession.decrypt(aliceCiphertext)
#expect(bobReceived == aliceMessage) #expect(bobReceived == aliceMessage)
// Bob -> Alice // Bob -> Alice
let bobMessage = "Hello from Bob".data(using: .utf8)! let bobMessage = Data("Hello from Bob".utf8)
let bobCiphertext = try bobSession.encrypt(bobMessage) let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext) let aliceReceived = try aliceSession.decrypt(bobCiphertext)
#expect(aliceReceived == bobMessage) #expect(aliceReceived == bobMessage)
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
} }
@Test func encryptionBeforeHandshake() { @Test func encryptionBeforeHandshake() {
let plaintext = "test".data(using: .utf8)! let plaintext = Data("test".utf8)
#expect(throws: NoiseSessionError.notEstablished) { #expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.encrypt(plaintext) try aliceSession.encrypt(plaintext)
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Encrypt with manager // Encrypt with manager
let plaintext = "Test message".data(using: .utf8)! let plaintext = Data("Test message".utf8)
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID) let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager // Decrypt with manager
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
@Test func tamperedCiphertextDetection() throws { @Test func tamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Secret message".data(using: .utf8)! let plaintext = Data("Secret message".utf8)
var ciphertext = try aliceSession.encrypt(plaintext) var ciphertext = try aliceSession.encrypt(plaintext)
// Tamper with ciphertext // Tamper with ciphertext
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
@Test func replayPrevention() throws { @Test func replayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Test message".data(using: .utf8)! let plaintext = Data("Test message".utf8)
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
// First decryption should succeed // First decryption should succeed
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession2, responder: bobSession2) try performHandshake(initiator: aliceSession2, responder: bobSession2)
// Encrypt with session 1 // Encrypt with session 1
let plaintext = "Secret".data(using: .utf8)! let plaintext = Data("Secret".utf8)
let ciphertext1 = try aliceSession1.encrypt(plaintext) let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2 // Should not be able to decrypt with session 2
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state // Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID) let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID)
_ = try bobManager.decrypt(message1, from: bobPeerID) _ = try bobManager.decrypt(message1, from: bobPeerID)
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID) let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID)
_ = try aliceManager.decrypt(message2, from: alicePeerID) _ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key // Simulate Bob restart by creating new manager with same key
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions // Should be able to exchange messages with new sessions
let testMessage = "After restart".data(using: .utf8)! let testMessage = Data("After restart".utf8)
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID) let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
#expect(decrypted == testMessage) #expect(decrypted == testMessage)
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
// Exchange messages to advance nonces // Exchange messages to advance nonces
for i in 0..<5 { for i in 0..<5 {
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!) let msg = try aliceSession.encrypt(Data("Message \(i)".utf8))
_ = try bobSession.decrypt(msg) _ = try bobSession.decrypt(msg)
} }
// Simulate desynchronization by encrypting but not decrypting // Simulate desynchronization by encrypting but not decrypting
for i in 0..<3 { for i in 0..<3 {
_ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!) _ = try aliceSession.encrypt(Data("Lost message \(i)".utf8))
} }
// With per-packet nonce carried, decryption should not throw here // With per-packet nonce carried, decryption should not throw here
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!) let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8))
#expect(throws: Never.self) { #expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage) try bobSession.decrypt(desyncMessage)
} }
@@ -434,12 +434,11 @@ struct NoiseProtocolTests {
let messageCount = 100 let messageCount = 100
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
{ completion in
var encryptedMessages: [Int: Data] = [:] var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager // Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount { for i in 0..<messageCount {
let plaintext = "Concurrent message \(i)".data(using: .utf8)! let plaintext = Data("Concurrent message \(i)".utf8)
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID) let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted encryptedMessages[i] = encrypted
} }
@@ -452,7 +451,7 @@ struct NoiseProtocolTests {
return return
} }
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = "Concurrent message \(i)".data(using: .utf8)! let expected = Data("Concurrent message \(i)".utf8)
#expect(decrypted == expected) #expect(decrypted == expected)
completion() completion()
} catch { } catch {
@@ -485,7 +484,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message // Create a corrupted message
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID) var encrypted = try aliceManager.encrypt(Data("Test".utf8), for: alicePeerID)
encrypted[10] ^= 0xFF // Corrupt the data encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail // Decryption should fail
@@ -516,7 +515,7 @@ struct NoiseProtocolTests {
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work // Exchange messages to verify sessions work
let testMessage = "Session works".data(using: .utf8)! let testMessage = Data("Session works".utf8)
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID) let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
#expect(decrypted == testMessage) #expect(decrypted == testMessage)
@@ -539,7 +538,7 @@ struct NoiseProtocolTests {
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!) _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work // Verify new sessions work
let testMessage2 = "New session works".data(using: .utf8)! let testMessage2 = Data("New session works".utf8)
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID) let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID) let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
#expect(decrypted2 == testMessage2) #expect(decrypted2 == testMessage2)
@@ -555,18 +554,18 @@ struct NoiseProtocolTests {
// Exchange messages normally // Exchange messages normally
for i in 0..<5 { for i in 0..<5 {
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID) let msg = try aliceManager.encrypt(Data("Message \(i)".utf8), for: alicePeerID)
_ = try bobManager.decrypt(msg, from: bobPeerID) _ = try bobManager.decrypt(msg, from: bobPeerID)
} }
// Simulate desynchronization - Alice sends messages that Bob doesn't receive // Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 { for i in 0..<3 {
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID) _ = try aliceManager.encrypt(Data("Lost message \(i)".utf8), for: alicePeerID)
} }
// With nonce carried in packet, decryption should not throw here // With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt( let desyncMessage = try aliceManager.encrypt(
"This now succeeds".data(using: .utf8)!, for: alicePeerID) Data("This now succeeds".utf8), for: alicePeerID)
#expect(throws: Never.self) { #expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID) try bobManager.decrypt(desyncMessage, from: bobPeerID)
} }
@@ -587,7 +586,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again // Verify communication works again
let testResynced = "Resynced".data(using: .utf8)! let testResynced = Data("Resynced".utf8)
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID) let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID) let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
#expect(decryptedResync == testResynced) #expect(decryptedResync == testResynced)
@@ -900,20 +899,20 @@ struct NoiseProtocolTests {
#expect(trackingKeychain.secureClearDataCallCount > 0) #expect(trackingKeychain.secureClearDataCallCount > 0)
// Test encryption from Alice to Bob // Test encryption from Alice to Bob
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)! let plaintext1 = Data("Hello from Alice after secureClear!".utf8)
let ciphertext1 = try alice.encrypt(plaintext1) let ciphertext1 = try alice.encrypt(plaintext1)
let decrypted1 = try bob.decrypt(ciphertext1) let decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1) #expect(decrypted1 == plaintext1)
// Test encryption from Bob to Alice // Test encryption from Bob to Alice
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)! let plaintext2 = Data("Hello from Bob after secureClear!".utf8)
let ciphertext2 = try bob.encrypt(plaintext2) let ciphertext2 = try bob.encrypt(plaintext2)
let decrypted2 = try alice.decrypt(ciphertext2) let decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2) #expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct // Test multiple messages to verify cipher state is correct
for i in 1...10 { for i in 1...10 {
let msg = "Message \(i) from Alice".data(using: .utf8)! let msg = Data("Message \(i) from Alice".utf8)
let cipher = try alice.encrypt(msg) let cipher = try alice.encrypt(msg)
let dec = try bob.decrypt(cipher) let dec = try bob.decrypt(cipher)
#expect(dec == msg) #expect(dec == msg)
+107
View File
@@ -0,0 +1,107 @@
//
// NoiseCourierTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
/// One-way Noise X envelopes: encryption to a known static key without an
/// interactive handshake, used by the courier store-and-forward path.
struct NoiseCourierTests {
@Test func sealAndOpenRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let payload = Data("meet at the north gate".utf8)
let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
let opened = try bob.openCourierPayload(sealed)
#expect(opened.payload == payload)
// The X pattern authenticates the sender: Bob learns Alice's real static key.
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
}
@Test func wrongRecipientCannotOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let carol = NoiseEncryptionService(keychain: MockKeychain())
let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
#expect(throws: (any Error).self) {
_ = try carol.openCourierPayload(sealed)
}
}
@Test func tamperedEnvelopeFailsToOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
sealed[sealed.count - 1] ^= 0x01
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(sealed)
}
}
@Test func senderIdentityCannotBeForged() throws {
// The encrypted static key inside the envelope is bound by the ss DH;
// splicing one envelope's ephemeral prefix onto another must fail.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = bob.getStaticPublicKeyData()
let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
// e (32 bytes) from Mallory's envelope + rest from Alice's.
let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32)
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(Data(spliced))
}
}
@Test func sealRejectsInvalidRecipientKey() {
let alice = NoiseEncryptionService(keychain: MockKeychain())
#expect(throws: (any Error).self) {
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32))
}
#expect(throws: (any Error).self) {
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8))
}
}
@Test func emptyAndLargePayloadsRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = bob.getStaticPublicKeyData()
let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey)
#expect(try bob.openCourierPayload(empty).payload.isEmpty)
let large = Data((0..<8192).map { UInt8($0 % 251) })
let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey)
#expect(try bob.openCourierPayload(sealed).payload == large)
}
@Test func envelopesAreNotLinkableAcrossSends() throws {
// Fresh ephemeral per seal: same payload to the same recipient must
// produce entirely different ciphertexts.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let payload = Data("same message".utf8)
let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
#expect(a != b)
#expect(a.prefix(32) != b.prefix(32))
}
}
-153
View File
@@ -289,159 +289,6 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42) #expect(object["limit"] as? Int == 42)
} }
// MARK: - Padding (v3 envelope)
@Test func paddedLengthMatchesNIP44Buckets() {
// Vectors from the NIP-44 reference test suite (calc_padded_len).
let vectors: [(Int, Int)] = [
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
(1020, 1024), (65535, 65536)
]
for (unpadded, expected) in vectors {
#expect(
NIP44Padding.paddedLength(for: unpadded) == expected,
"paddedLength(for: \(unpadded)) should be \(expected)"
)
}
}
@Test func padUnpadRoundTrip() throws {
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let padded = try NIP44Padding.pad(plaintext)
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
let unpadded = try NIP44Padding.unpad(padded)
#expect(unpadded == plaintext)
}
}
@Test func padHidesExactLengthWithinBucket() throws {
// Two plaintexts of different length in the same bucket must produce
// identically sized padded payloads (and thus ciphertexts).
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
#expect(short.count == long.count)
}
@Test func padRejectsOutOfRangePlaintexts() {
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
}
@Test func unpadRejectsTamperedLengthPrefix() throws {
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
// Claimed length larger than the actual payload
var tooLong = padded
tooLong[tooLong.startIndex] = 0xFF
tooLong[tooLong.startIndex + 1] = 0xFF
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
// Claimed length of zero
var zero = padded
zero[zero.startIndex] = 0x00
zero[zero.startIndex + 1] = 0x00
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
// Claimed length whose bucket does not match the payload size
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
var wrongBucket = padded
wrongBucket[wrongBucket.startIndex] = 0x00
wrongBucket[wrongBucket.startIndex + 1] = 0x14
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
// Truncated payloads
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
padded.removeLast()
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
// Works on Data slices with non-zero startIndex
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
}
@Test func paddedEnvelopeRoundTrip_v3() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "padded envelope test"
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: true
)
#expect(ciphertext.hasPrefix("v3:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "legacy v2 envelope"
// What deployed clients send today.
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
#expect(ciphertext.hasPrefix("v2:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
// Deployed clients reject anything that is not "v2:", so the padded
// envelope must stay off by default until decrypt-side support is
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
#expect(NostrProtocol.sendPaddedEnvelope == false)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "default envelope",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(giftWrap.content.hasPrefix("v2:"))
}
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let ciphertext = try NostrProtocol.encrypt(
plaintext: "test",
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
let mutated = "v9:" + ciphertext.dropFirst(3)
#expect(throws: NostrError.invalidCiphertext) {
_ = try NostrProtocol.decrypt(
ciphertext: mutated,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
}
}
// MARK: - Helpers // MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? { private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
@@ -301,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]), ("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
("checking in from the harbor #bitchat #mesh", nil), ("checking in from the harbor #bitchat #mesh", nil),
("@bob#0042 ping me when you get this", ["bob#0042"]), ("@bob#0042 ping me when you get this", ["bob#0042"]),
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil), ("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil)
] ]
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
(0..<batchSize).map { i in (0..<batchSize).map { i in
@@ -547,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
"anyone near the station?", "anyone near the station?",
"@bob#0042 are you on mesh too?", "@bob#0042 are you on mesh too?",
"check this out https://example.com/p/123 #bitchat", "check this out https://example.com/p/123 #bitchat",
"teleport check, who's local?", "teleport check, who's local?"
] ]
return try (0..<count).map { i in return try (0..<count).map { i in
try NostrProtocol.createEphemeralGeohashEvent( try NostrProtocol.createEphemeralGeohashEvent(
+10 -5
View File
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
struct ProtocolContractTests { struct ProtocolContractTests {
@Test @Test
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() { func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
#expect(CommandInfo.message.id == "dm") // Aliases must match what CommandProcessor actually accepts
#expect(CommandInfo.message.alias == "/dm") // the suggestion panel is the only command-discovery surface.
#expect(CommandInfo.message.id == "msg")
#expect(CommandInfo.message.alias == "/msg")
#expect(CommandInfo.message.placeholder != nil) #expect(CommandInfo.message.placeholder != nil)
#expect(CommandInfo.clear.placeholder == nil) #expect(CommandInfo.clear.placeholder == nil)
#expect(CommandInfo.favorite.description.isEmpty == false) #expect(CommandInfo.favorite.description.isEmpty == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false) #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite)) // Favorites are rejected by the processor in geohash contexts, so
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite)) // they are suggested only in mesh.
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
} }
@Test @Test
@@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isDirectAnnounce == true)
#expect(result?.isVerified == true)
#expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
#expect(recorder.barrierCount == 1) #expect(recorder.barrierCount == 1)
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.isEmpty) #expect(recorder.verifySignatureCalls.isEmpty)
#expect(recorder.barrierCount == 1) #expect(recorder.barrierCount == 1)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
@@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests {
recorder.signatureValid = false recorder.signatureValid = false
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
@@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result?.isDirectAnnounce == false)
#expect(result?.isVerified == true)
#expect(recorder.upsertCalls.count == 1) #expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false) #expect(recorder.upsertCalls.first?.isConnected == false)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
@@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests {
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
@@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests {
#expect(selection.centralIDs == Set(["c2"])) #expect(selection.centralIDs == Set(["c2"]))
} }
@Test
func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["target-p", "bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"target-p": target,
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs == Set(["target-p"]))
#expect(selection.centralIDs.isEmpty)
}
@Test
func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs == Set(["target-c"]))
}
@Test
func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: .central("target-c"),
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs.isEmpty)
}
@Test @Test
func directedSendExcludesAllLinksToIngressPeer() { func directedSendExcludesAllLinksToIngressPeer() {
let selection = BLEFanoutSelector.selectLinks( let selection = BLEFanoutSelector.selectLinks(
@@ -49,6 +49,21 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
XCTAssertFalse(service.isMutualFavorite(peerKey)) XCTAssertFalse(service.isMutualFavorite(peerKey))
} }
func test_updatePeerFavoritedUs_keepsStoredNicknameOverUnknownPlaceholder() {
let service = FavoritesPersistenceService(keychain: MockKeychain())
let peerKey = Data((128..<160).map(UInt8.init))
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Erin")
// A notification arriving before the peer is known passes "Unknown".
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Unknown")
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin")
// A real nickname still updates the stored one.
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Erin2")
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin2")
}
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() { func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
let service = FavoritesPersistenceService(keychain: MockKeychain()) let service = FavoritesPersistenceService(keychain: MockKeychain())
let peerKey = Data((96..<128).map(UInt8.init)) let peerKey = Data((96..<128).map(UInt8.init))
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Combine
import Foundation import Foundation
import Testing import Testing
import BitFoundation import BitFoundation
@@ -42,7 +43,9 @@ struct NostrTransportTests {
) )
) )
#expect(!transport.isPeerReachable(fullPeerID)) // Offline favorites are addressed by the full 64-hex noise key, so
// both forms must resolve to the same reachability answer.
#expect(transport.isPeerReachable(fullPeerID))
#expect(transport.isPeerReachable(shortPeerID)) #expect(transport.isPeerReachable(shortPeerID))
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed"))) #expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
} }
@@ -83,6 +86,51 @@ struct NostrTransportTests {
#expect(didRefresh) #expect(didRefresh)
} }
@Test("Prompt delivery requires both a known npub and a relay connection")
@MainActor
func canDeliverPromptlyTracksRelayConnectivity() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let recipient = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Alice"
)
let connectivity = CurrentValueSubject<Bool, Never>(false)
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
loadFavorites: { [noiseKey: relationship] },
relayConnectivity: { connectivity.eraseToAnyPublisher() }
)
)
// Reachable (npub known) but relays down: the peer must not be
// treated as promptly deliverable, or the router would skip the
// courier and let the message rot in the Nostr send queue.
#expect(transport.isPeerReachable(peerID))
#expect(!transport.canDeliverPromptly(to: peerID))
connectivity.send(true)
let deliverable = await TestHelpers.waitUntil(
{ transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(deliverable)
connectivity.send(false)
let undeliverable = await TestHelpers.waitUntil(
{ !transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(undeliverable)
}
@Test("Private message resolves short peer ID and emits decryptable packet") @Test("Private message resolves short peer ID and emits decryptable packet")
@MainActor @MainActor
func sendPrivateMessageResolvesShortPeerID() async throws { func sendPrivateMessageResolvesShortPeerID() async throws {
@@ -373,7 +421,8 @@ struct NostrTransportTests {
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil }, currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in }, registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in }, sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in } scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
) -> NostrTransport.Dependencies { ) -> NostrTransport.Dependencies {
NostrTransport.Dependencies( NostrTransport.Dependencies(
notificationCenter: notificationCenter, notificationCenter: notificationCenter,
@@ -383,7 +432,8 @@ struct NostrTransportTests {
currentIdentity: currentIdentity, currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap, registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent, sendEvent: sendEvent,
scheduleAfter: scheduleAfter scheduleAfter: scheduleAfter,
relayConnectivity: relayConnectivity
) )
} }
@@ -41,6 +41,155 @@ struct UnifiedPeerServiceTests {
#expect(service.isBlocked(peerID)) #expect(service.isBlocked(peerID))
} }
@Test @MainActor
func setBlocked_persistsByFingerprintAndToggles() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let peerID = PeerID(str: "00000000000000EE")
let fingerprint = "fp-target"
transport.peerFingerprints[peerID] = fingerprint
// Blocking resolves and persists by the peer's fingerprint.
let resolved = service.setBlocked(peerID, blocked: true)
#expect(resolved == fingerprint)
#expect(identity.isBlocked(fingerprint: fingerprint))
#expect(service.isBlocked(peerID))
// Unblocking clears it against the same identity.
let unresolved = service.setBlocked(peerID, blocked: false)
#expect(unresolved == fingerprint)
#expect(!identity.isBlocked(fingerprint: fingerprint))
#expect(!service.isBlocked(peerID))
}
// MARK: - Offline-favorite dedup (updatePeers phase 2)
/// A mutual favorite that is also on the mesh must collapse to a single
/// row keyed by the short mesh ID even when the announced nickname no
/// longer matches the one stored with the favorite.
@Test @MainActor
func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async {
let favoritesService = FavoritesPersistenceService.shared
let transport = MockTransport()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
let noiseKey = Data(repeating: 0xAB, count: 32)
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice")
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
defer {
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
}
let meshID = PeerID(publicKey: noiseKey)
let snapshots = [TransportPeerSnapshot(
peerID: meshID,
nickname: "alice-renamed",
isConnected: true,
noisePublicKey: noiseKey,
lastSeen: Date()
)]
transport.updatePeerSnapshots(snapshots)
service.didUpdatePeerSnapshots(snapshots)
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
#expect(rows.count == 1)
#expect(rows.first?.peerID == meshID)
#expect(rows.first?.isMutualFavorite == true)
#expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1)
}
/// Same collapse must hold for a reachable-but-not-connected favorite
/// (relayed peers linger as "reachable" after their link drops).
@Test @MainActor
func updatePeers_reachableMutualFavoriteYieldsSingleRow() async {
let favoritesService = FavoritesPersistenceService.shared
let transport = MockTransport()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
let noiseKey = Data(repeating: 0xCD, count: 32)
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob")
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
defer {
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
}
let otherKey = Data(repeating: 0x11, count: 32)
let snapshots = [
// A live link is required for anyone to count as reachable.
TransportPeerSnapshot(
peerID: PeerID(publicKey: otherKey),
nickname: "carol",
isConnected: true,
noisePublicKey: otherKey,
lastSeen: Date()
),
TransportPeerSnapshot(
peerID: PeerID(publicKey: noiseKey),
nickname: "bob",
isConnected: false,
noisePublicKey: noiseKey,
lastSeen: Date()
)
]
transport.updatePeerSnapshots(snapshots)
service.didUpdatePeerSnapshots(snapshots)
let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey }
#expect(bobRows.count == 1)
#expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey))
#expect(bobRows.first?.isReachable == true)
}
/// A mutual favorite with no mesh presence still gets its offline row,
/// keyed by the full noise-key PeerID.
@Test @MainActor
func updatePeers_offlineMutualFavoriteGetsOfflineRow() async {
let favoritesService = FavoritesPersistenceService.shared
let transport = MockTransport()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
let noiseKey = Data(repeating: 0xEF, count: 32)
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave")
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
defer {
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
}
transport.updatePeerSnapshots([])
service.didUpdatePeerSnapshots([])
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
#expect(rows.count == 1)
#expect(rows.first?.peerID == PeerID(hexData: noiseKey))
#expect(rows.first?.isMutualFavorite == true)
}
@Test @MainActor
func setBlocked_unknownIdentityReturnsNil() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
// No fingerprint resolvable for this peer (offline & unknown).
let peerID = PeerID(str: "00000000000000FF")
#expect(service.setBlocked(peerID, blocked: true) == nil)
#expect(!service.isBlocked(peerID))
}
} }
private final class TestIdentityManager: SecureIdentityStateManagerProtocol { private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
@@ -12,6 +12,11 @@ import Foundation
struct TestConstants { struct TestConstants {
static let defaultTimeout: TimeInterval = 5.0 static let defaultTimeout: TimeInterval = 5.0
static let shortTimeout: TimeInterval = 1.0 static let shortTimeout: TimeInterval = 1.0
/// For positive waits on work that hops through `Task.detached` or
/// background queues: those contend with every parallel test worker for
/// the global executor, so a loaded CI runner can exceed
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
/// so passing runs never pay the longer timeout.
static let longTimeout: TimeInterval = 10.0 static let longTimeout: TimeInterval = 10.0
static let testNickname1 = "Alice" static let testNickname1 = "Alice"
+2 -2
View File
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01, type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString), senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil, recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!, payload: Data = Data("test payload".utf8),
signature: Data? = nil, signature: Data? = nil,
ttl: UInt8 = 3 ttl: UInt8 = 3
) -> BitchatPacket { ) -> BitchatPacket {
return BitchatPacket( return BitchatPacket(
type: type, type: type,
senderID: senderID.id.data(using: .utf8)!, senderID: Data(senderID.id.utf8),
recipientID: recipientID?.id.data(using: .utf8), recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
+13 -3
View File
@@ -556,11 +556,19 @@ struct ViewSmokeTests {
@Test @Test
func voiceAndMediaViews_renderAndWarmCaches() async throws { func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL() let audioURL = try makeTemporaryAudioURL()
// Probed directly below. Deliberately a separate file from `audioURL`:
// `WaveformCache.shared` is process-wide and the mounted
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
// width concurrently, so asserting an exact bin count for that URL
// races with the view's own cache write.
let waveformProbeURL = try makeTemporaryAudioURL()
let imageURL = try makeTemporaryImageURL() let imageURL = try makeTemporaryImageURL()
defer { defer {
try? FileManager.default.removeItem(at: audioURL) try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: waveformProbeURL)
try? FileManager.default.removeItem(at: imageURL) try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL) WaveformCache.shared.purge(url: audioURL)
WaveformCache.shared.purge(url: waveformProbeURL)
} }
let waveformView = WaveformView( let waveformView = WaveformView(
@@ -594,12 +602,14 @@ struct ViewSmokeTests {
_ = mount(voiceNoteView) _ = mount(voiceNoteView)
let bins = await withCheckedContinuation { continuation in let bins = await withCheckedContinuation { continuation in
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in
continuation.resume(returning: values) continuation.resume(returning: values)
} }
} }
playback.loadDuration() playback.loadDuration()
try? await Task.sleep(nanoseconds: 250_000_000) // loadDuration hops through a background queue and back to main; poll
// instead of a fixed sleep so a loaded runner can't outlast the wait.
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
playback.seek(to: 1.25) playback.seek(to: 1.25)
playback.stop() playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback) VoiceNotePlaybackCoordinator.shared.activate(playback)
@@ -607,7 +617,7 @@ struct ViewSmokeTests {
await VoiceRecorder.shared.cancelRecording() await VoiceRecorder.shared.cancelRecording()
#expect(bins.count == 16) #expect(bins.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16) #expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
#expect(playback.duration > 0) #expect(playback.duration > 0)
#expect(playback.progress == 0) #expect(playback.progress == 0)
} }
+12 -12
View File
@@ -13,7 +13,7 @@ import struct Foundation.Data
struct XChaCha20Poly1305CompatTests { struct XChaCha20Poly1305CompatTests {
@Test func sealAndOpenRoundtrip() throws { @Test func sealAndOpenRoundtrip() throws {
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)! let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealAndOpenWithAAD() throws { @Test func sealAndOpenWithAAD() throws {
let plaintext = "Secret message".data(using: .utf8)! let plaintext = Data("Secret message".utf8)
let key = Data(repeating: 0xAB, count: 32) let key = Data(repeating: 0xAB, count: 32)
let nonce = Data(repeating: 0xCD, count: 24) let nonce = Data(repeating: 0xCD, count: 24)
let aad = "additional authenticated data".data(using: .utf8)! let aad = Data("additional authenticated data".utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
let decrypted = try XChaCha20Poly1305Compat.open( let decrypted = try XChaCha20Poly1305Compat.open(
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws { @Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
let plaintext = "Same plaintext".data(using: .utf8)! let plaintext = Data("Same plaintext".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let nonce1 = Data(repeating: 0x01, count: 24) let nonce1 = Data(repeating: 0x01, count: 24)
let nonce2 = Data(repeating: 0x02, count: 24) let nonce2 = Data(repeating: 0x02, count: 24)
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnShortKey() { @Test func sealThrowsOnShortKey() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let shortKey = Data(repeating: 0x42, count: 16) let shortKey = Data(repeating: 0x42, count: 16)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnLongKey() { @Test func sealThrowsOnLongKey() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let longKey = Data(repeating: 0x42, count: 64) let longKey = Data(repeating: 0x42, count: 64)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnEmptyKey() { @Test func sealThrowsOnEmptyKey() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let emptyKey = Data() let emptyKey = Data()
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnShortNonce() { @Test func sealThrowsOnShortNonce() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let shortNonce = Data(repeating: 0x24, count: 12) let shortNonce = Data(repeating: 0x24, count: 12)
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnLongNonce() { @Test func sealThrowsOnLongNonce() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let longNonce = Data(repeating: 0x24, count: 32) let longNonce = Data(repeating: 0x24, count: 32)
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnEmptyNonce() { @Test func sealThrowsOnEmptyNonce() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let emptyNonce = Data() let emptyNonce = Data()
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func openFailsWithWrongKey() throws { @Test func openFailsWithWrongKey() throws {
let plaintext = "Secret".data(using: .utf8)! let plaintext = Data("Secret".utf8)
let correctKey = Data(repeating: 0x42, count: 32) let correctKey = Data(repeating: 0x42, count: 32)
let wrongKey = Data(repeating: 0x43, count: 32) let wrongKey = Data(repeating: 0x43, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func openFailsWithTamperedCiphertext() throws { @Test func openFailsWithTamperedCiphertext() throws {
let plaintext = "Secret".data(using: .utf8)! let plaintext = Data("Secret".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
+7 -7
View File
@@ -5,16 +5,16 @@ let package = Package(
name: "Tor", // Keep name "Tor" for drop-in compatibility name: "Tor", // Keep name "Tor" for drop-in compatibility
platforms: [ platforms: [
.iOS(.v16), .iOS(.v16),
.macOS(.v13), .macOS(.v13)
], ],
products: [ products: [
.library( .library(
name: "Tor", name: "Tor",
targets: ["Tor"] targets: ["Tor"]
), )
], ],
dependencies: [ dependencies: [
.package(path: "../BitLogger"), .package(path: "../BitLogger")
], ],
targets: [ targets: [
// Main Swift target // Main Swift target
@@ -22,19 +22,19 @@ let package = Package(
name: "Tor", name: "Tor",
dependencies: [ dependencies: [
"arti", "arti",
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger")
], ],
path: "Sources", path: "Sources",
exclude: ["C"], exclude: ["C"],
sources: [ sources: [
"TorManager.swift", "TorManager.swift",
"TorURLSession.swift", "TorURLSession.swift",
"TorNotifications.swift", "TorNotifications.swift"
], ],
linkerSettings: [ linkerSettings: [
.linkedLibrary("resolv"), .linkedLibrary("resolv"),
.linkedLibrary("z"), .linkedLibrary("z"),
.linkedLibrary("sqlite3"), .linkedLibrary("sqlite3")
] ]
), ),
// Binary framework containing the Rust static library. // Binary framework containing the Rust static library.
@@ -42,6 +42,6 @@ let package = Package(
.binaryTarget( .binaryTarget(
name: "arti", name: "arti",
path: "Frameworks/arti.xcframework" path: "Frameworks/arti.xcframework"
), )
] ]
) )
+1 -1
View File
@@ -21,7 +21,7 @@ let package = Package(
.target( .target(
name: "BitFoundation", name: "BitFoundation",
dependencies: [ dependencies: [
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger")
], ],
path: "Sources" path: "Sources"
), ),
@@ -0,0 +1,147 @@
//
// CourierEnvelope.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
private import CryptoKit
/// TLV payload for store-and-forward courier envelopes.
///
/// A courier envelope lets a mutual favorite physically carry an encrypted
/// message to a peer who is currently offline. The envelope is opaque to the
/// courier: the only routing information is a rotating recipient tag derived
/// from the recipient's Noise static public key and the UTC day, so envelopes
/// addressed to the same peer on different days do not correlate for
/// observers who don't already know that peer's public key.
public struct CourierEnvelope: Equatable {
/// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated.
public let recipientTag: Data
/// Milliseconds since epoch after which the envelope must be discarded.
public let expiry: UInt64
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
public let ciphertext: Data
public static let tagLength = 16
/// Couriered messages are text-sized; media transfers are out of scope.
public static let maxCiphertextBytes = 16 * 1024
/// Matches the outbox retention policy in MessageRouter.
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
private enum TLVType: UInt8 {
case recipientTag = 0x01
case expiry = 0x02
case ciphertext = 0x03
}
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
}
public var isExpired: Bool {
isExpired(at: Date())
}
public func isExpired(at date: Date) -> Bool {
UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry
}
public func encode() -> Data? {
guard recipientTag.count == Self.tagLength else { return nil }
guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count)
encoded.append(TLVType.recipientTag.rawValue)
appendBE(UInt16(recipientTag.count), into: &encoded)
encoded.append(recipientTag)
encoded.append(TLVType.expiry.rawValue)
appendBE(UInt16(8), into: &encoded)
appendBE(expiry, into: &encoded)
encoded.append(TLVType.ciphertext.rawValue)
appendBE(UInt16(ciphertext.count), into: &encoded)
encoded.append(ciphertext)
return encoded
}
public static func decode(_ data: Data) -> CourierEnvelope? {
var cursor = data.startIndex
let end = data.endIndex
var recipientTag: Data?
var expiry: UInt64?
var ciphertext: Data?
while cursor < end {
let typeRaw = data[cursor]
cursor = data.index(after: cursor)
guard data.distance(from: cursor, to: end) >= 2 else { return nil }
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
cursor = data.index(cursor, offsetBy: 2)
guard data.distance(from: cursor, to: end) >= length else { return nil }
let value = data[cursor..<data.index(cursor, offsetBy: length)]
cursor = data.index(cursor, offsetBy: length)
switch TLVType(rawValue: typeRaw) {
case .recipientTag:
guard length == tagLength else { return nil }
recipientTag = Data(value)
case .expiry:
guard length == 8 else { return nil }
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .ciphertext:
guard length > 0, length <= maxCiphertextBytes else { return nil }
ciphertext = Data(value)
case nil:
// Unknown TLV: skip for forward compatibility.
continue
}
}
guard let recipientTag, let expiry, let ciphertext else { return nil }
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
}
// MARK: - Recipient Tags
private static let tagContext = Data("bitchat-courier-tag-v1".utf8)
/// UTC day number used to rotate recipient tags.
public static func epochDay(for date: Date) -> UInt32 {
UInt32(max(0, date.timeIntervalSince1970) / 86_400)
}
/// Rotating recipient hint for a given day. Computable only by parties
/// who already know the recipient's Noise static public key.
public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
var message = tagContext
withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey))
return Data(mac).prefix(tagLength)
}
/// Tags to test when checking whether an envelope is addressed to a peer.
/// Covers the adjacent days so envelopes sealed near midnight (or across
/// modest clock skew) still match while being carried.
public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] {
let day = epochDay(for: date)
return [day == 0 ? 0 : day - 1, day, day + 1].map {
recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0)
}
}
}
@@ -8,44 +8,12 @@
import struct Foundation.Data import struct Foundation.Data
/// Lowercase hex digits used by `hexEncodedString()`.
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
/// Accepts both lowercase and uppercase hex digits.
@inline(__always)
private func hexNibble(_ ascii: UInt8) -> UInt8? {
switch ascii {
case UInt8(ascii: "0")...UInt8(ascii: "9"):
return ascii - UInt8(ascii: "0")
case UInt8(ascii: "a")...UInt8(ascii: "f"):
return ascii - UInt8(ascii: "a") + 10
case UInt8(ascii: "A")...UInt8(ascii: "F"):
return ascii - UInt8(ascii: "A") + 10
default:
return nil
}
}
public extension Data { public extension Data {
/// Lowercase hex representation of the bytes.
///
/// Lookup-table based: this sits on the hot BLE receive path (it is called
/// several times per received packet via `PeerID(hexData:)`), where the
/// previous per-byte `String(format: "%02x", _)` implementation spent most
/// of its time re-parsing the format string through Foundation.
func hexEncodedString() -> String { func hexEncodedString() -> String {
if isEmpty { if self.isEmpty {
return "" return ""
} }
var output = [UInt8](repeating: 0, count: count * 2) return self.map { String(format: "%02x", $0) }.joined()
var i = 0
for byte in self {
output[i] = hexDigits[Int(byte >> 4)]
output[i + 1] = hexDigits[Int(byte & 0x0F)]
i += 2
}
return String(decoding: output, as: UTF8.self)
} }
/// Initialize Data from a hex string. /// Initialize Data from a hex string.
@@ -60,28 +28,28 @@ public extension Data {
hex = String(hex.dropFirst(2)) hex = String(hex.dropFirst(2))
} }
let ascii = Array(hex.utf8)
// Reject odd-length strings // Reject odd-length strings
guard ascii.count % 2 == 0 else { guard hex.count % 2 == 0 else {
return nil return nil
} }
// Accept empty strings // Reject empty strings
guard !ascii.isEmpty else { guard !hex.isEmpty else {
self = Data() self = Data()
return return
} }
var data = Data(capacity: ascii.count / 2) let len = hex.count / 2
var index = 0 var data = Data(capacity: len)
while index < ascii.count { var index = hex.startIndex
guard let high = hexNibble(ascii[index]),
let low = hexNibble(ascii[index + 1]) else { for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil return nil
} }
data.append((high << 4) | low) data.append(byte)
index += 2 index = nextIndex
} }
self = data self = data
@@ -11,17 +11,20 @@ import struct Foundation.Date
public enum DeliveryStatus: Codable, Equatable, Hashable { public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending case sending
case sent // Left our device case sent // Left our device
case carried // Sealed envelope handed to a courier; best-effort physical delivery
case delivered(to: String, at: Date) // Confirmed by recipient case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient case read(by: String, at: Date) // Seen by recipient
case failed(reason: String) case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String { public var displayText: String {
switch self { switch self {
case .sending: case .sending:
return "Sending..." return "Sending..."
case .sent: case .sent:
return "Sent" return "Sent"
case .carried:
return "Carried by a friend"
case .delivered(let nickname, _): case .delivered(let nickname, _):
return "Delivered to \(nickname)" return "Delivered to \(nickname)"
case .read(let nickname, _): case .read(let nickname, _):
@@ -12,8 +12,9 @@
public enum MessageType: UInt8 { public enum MessageType: UInt8 {
// Public messages (unencrypted) // Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving" case leave = 0x03 // "I'm leaving"
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
case requestSync = 0x21 // GCS filter-based sync request (local-only) case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
case .requestSync: return "requestSync" case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"

Some files were not shown because too many files have changed in this diff Show More